@homie-rocks/studio 0.11.0 → 0.12.1
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 +51 -6
- package/bin/homie-studio.mjs +25 -4
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +19 -7
- package/lib/codex.mjs +1 -0
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/players.mjs +38 -0
- package/lib/progress.mjs +39 -43
- package/lib/repo.mjs +51 -0
- package/lib/scaffold.mjs +31 -1
- package/lib/setup.mjs +15 -23
- package/lib/site.mjs +2 -2
- package/lib/stats.mjs +3 -3
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +48 -0
- package/netplay/NETPLAY.md +5 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/port/keys.ts +3 -0
- package/saves/SAVES.md +236 -0
- package/saves/saves.ts +326 -0
- package/site/SITE.md +9 -0
- package/starters/ember-vale/game.json +20 -0
- package/starters/ember-vale/index.html +46 -0
- package/starters/ember-vale/src/main.ts +429 -0
- package/worker/account-page.mjs +350 -0
- package/worker/index.mjs +36 -8
- package/worker/pages.mjs +10 -3
- package/worker/players.mjs +817 -0
- package/worker/saves-shell.mjs +388 -0
- package/worker/saves.mjs +287 -0
- package/worker/site.mjs +1 -1
- package/worker/stats-page.mjs +1 -0
- package/worker/stats.mjs +16 -1
- package/worker/version.mjs +1 -1
- package/worker/webauthn.mjs +259 -0
package/saves/SAVES.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Players and cloud saves
|
|
2
|
+
|
|
3
|
+
A studio's games can keep progress that lasts: a character that levels up for days, unlocks, a collection,
|
|
4
|
+
lifetime stats, a hardcore mode with a hall of the fallen. It follows the player to every device they sign in
|
|
5
|
+
on. Everything lives in the studio's **own** Worker and D1, on its own Cloudflare account: no third party, no
|
|
6
|
+
tracker, no cookie from anyone else, and no other studio can see these players.
|
|
7
|
+
|
|
8
|
+
## Rooms are rooms; saves are saves
|
|
9
|
+
|
|
10
|
+
A public room is tonight's match. It keeps the live round (bodies, the clock, the world) and **forgets everything
|
|
11
|
+
60 seconds after its last player leaves**. That is right for a round and wrong for a hero. So a persistent game
|
|
12
|
+
keeps two kinds of state apart:
|
|
13
|
+
|
|
14
|
+
| | The room (`@homie-rocks/studio/netplay`, `createRoom`) | The save (`@homie-rocks/studio/saves`) |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| Lasts | while anyone is in the room (+60 s) | until the player deletes it |
|
|
17
|
+
| Belongs to | the room; the host browser runs the rules | one player, one game |
|
|
18
|
+
| Holds | positions, enemies, pickups, the round's score | name, level, gold, inventory, unlocks, settings |
|
|
19
|
+
| Written | 20 times a second, in memory | when it changes, at most about once a second |
|
|
20
|
+
|
|
21
|
+
The pattern (the `ember-vale` starter does exactly this):
|
|
22
|
+
|
|
23
|
+
1. When the player arrives, **load** the hero from saves; until it loads, play as a level-1 body.
|
|
24
|
+
2. The host decides what happened (who killed what) and tells **that player's own browser** (a netplay event
|
|
25
|
+
to its seat). Only the hero's own browser changes the hero and **saves** it.
|
|
26
|
+
3. The room carries what the others need to see (my level, so my health bar is right), never the save itself.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
game.json:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{ "id": "my-rpg", "saves": true }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`src/main.ts`:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { createSaves } from '@homie-rocks/studio/saves'; // also in '@homie-rocks/studio/port'
|
|
40
|
+
|
|
41
|
+
interface Hero { name: string; level: number; xp: number; gold: number }
|
|
42
|
+
const saves = createSaves({ game: 'my-rpg' });
|
|
43
|
+
|
|
44
|
+
let hero = (await saves.get<Hero>('hero')) ?? { name: 'Wanderer', level: 1, xp: 0, gold: 0 };
|
|
45
|
+
// … the hero gains something:
|
|
46
|
+
hero.gold += 25;
|
|
47
|
+
await saves.set('hero', hero); // safe on this device at once; in the cloud when online
|
|
48
|
+
|
|
49
|
+
// The player signed in on this device (or signed out): these are someone else's saves now.
|
|
50
|
+
saves.on('player', async () => { hero = (await saves.get<Hero>('hero')) ?? newHero(); });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`"saves": true` turns on the play page's saves bridge for that game, and a "Your progress follows you" link on its
|
|
54
|
+
landing. Pressing Play never needs an account.
|
|
55
|
+
|
|
56
|
+
## Who the player is
|
|
57
|
+
|
|
58
|
+
- **A guest** is made the first time a game saves something (never on Play). Their progress is kept on the server
|
|
59
|
+
for that browser (a cookie that is this site's alone) and in that browser's local copy.
|
|
60
|
+
- **An account** is a guest (or anyone) who made a **passkey** on this site: Face ID, a fingerprint, a PIN or a
|
|
61
|
+
security key. No password, no email. A guest who makes an account keeps everything: it is the same player.
|
|
62
|
+
- **Another device**: "Sign in" with the same passkey. The device's own passkey sync carries it (iCloud Keychain,
|
|
63
|
+
Google Password Manager, a password manager), or the browser offers a phone's QR code. The same saves are there.
|
|
64
|
+
A guest who signs in to an account they already have keeps the account's saves, and anything only the guest had
|
|
65
|
+
(keys, stats, memorials the account lacks) moves over.
|
|
66
|
+
- **Recovery**: add another passkey on another device or a security key (`/account/`). A **recovery email** is
|
|
67
|
+
offered only when the studio has a mail sender: a Cloudflare Email Service binding named `PLAYER_MAIL`
|
|
68
|
+
(`"send_email": [{ "name": "PLAYER_MAIL" }]` in wrangler.jsonc; Email Sending needs the Workers paid plan and a
|
|
69
|
+
domain onboarded to it) and a `PLAYER_MAIL_FROM` variable on that domain. The address is confirmed by a
|
|
70
|
+
one-time link, and a recovery link lets the player make a new passkey; the old sessions end.
|
|
71
|
+
- **Names** follow the same rules as a room's names: one line, at most 24 characters, whitespace collapsed, no
|
|
72
|
+
control, zero-width or bidi characters, at least one letter or digit, never a role ("admin", "moderator",
|
|
73
|
+
"owner", "official"…) or the studio's own name, and always drawn as text. A guest's name is a two-word handle
|
|
74
|
+
until they choose one. A signed-in player plays under their name in every room of the studio.
|
|
75
|
+
- The game can show who is playing (`saves.player`: `name`, `guest`, `signedIn`, `owner`) and offer
|
|
76
|
+
`saves.signIn(reason)` from its own button: the play page shows its sign-in sheet over the game.
|
|
77
|
+
- **The account page** is `/account/` on the studio's site: make an account, sign in, name, passkeys (add,
|
|
78
|
+
remove), recovery email, download everything, delete everything.
|
|
79
|
+
|
|
80
|
+
Passkeys belong to the **host name** they were made on. Accounts made on a studio's workers.dev address do not
|
|
81
|
+
sign in on its custom domain later: give a persistent game its final address before people make accounts. A
|
|
82
|
+
Preview (a branch's own URL) has no database, so it has no accounts. In `npm run dev`, open
|
|
83
|
+
`http://localhost:8787` (not `127.0.0.1`): a passkey needs a name, not an IP address.
|
|
84
|
+
|
|
85
|
+
## The API
|
|
86
|
+
|
|
87
|
+
`createSaves(options?)`:
|
|
88
|
+
|
|
89
|
+
| Option | |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `game` | The game's id (names the local-only store). On a studio site the shell's own game id is used: a game can only ever reach its own saves. |
|
|
92
|
+
| `onConflict(c)` | Another device saved a key since this one's copy. Return the value to keep (merge `c.mine` and `c.theirs`), or `undefined` for the cloud's (the default). |
|
|
93
|
+
| `local` | `true` forces local-only mode (tests, a game outside a studio). |
|
|
94
|
+
|
|
95
|
+
| Call | |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `ready` | Resolves with the player once their saves are loaded. |
|
|
98
|
+
| `player` | `{ id, name, guest, owner, signedIn, local }`. |
|
|
99
|
+
| `peek(key)` | The last known value, at once (for a render loop). |
|
|
100
|
+
| `get(key)` | The value, or `null`. |
|
|
101
|
+
| `set(key, value)` | `{ ok, synced, version }`. JSON only. Resolves when synced, or after about 4 s with `synced: false` (it is safe on this device and syncs later). |
|
|
102
|
+
| `remove(key)` | Delete one key. |
|
|
103
|
+
| `list()` | `[{ key, version, updatedAt, pending }]`. |
|
|
104
|
+
| `wipe({ keep })` | Delete this game's saves except `keep` ("new game"). Stats and memorials stay. |
|
|
105
|
+
| `stats.add({…})`, `stats.max({…})`, `stats.min({…})`, `stats.get()` | Lifetime numbers that only add up, or keep the highest or lowest: kills, gold earned, seconds played, best level, fastest run. A wipe or a death keeps them. |
|
|
106
|
+
| `fall({ character, summary, wipe, keep })` | A memorial in the hall of the fallen; with `wipe: true`, this game's saves (except `keep`) are deleted in the same database transaction. |
|
|
107
|
+
| `fallen({ limit, mine })` | The hall of the fallen: the newest memorials of this game (`{ character, player, summary, at }`, no player id). |
|
|
108
|
+
| `rename(name)` | The player's display name on this studio. |
|
|
109
|
+
| `signIn(reason?)` | Show the sign-in sheet. Call it from a button the player pressed. |
|
|
110
|
+
| `flush()`, `status()` | Wait for the cloud; `{ mode, online, pending, lastSyncAt, lastError }`. |
|
|
111
|
+
| `on('player' \| 'synced' \| 'status' \| 'conflict' \| 'error', fn)` | Returns an unsubscribe function. |
|
|
112
|
+
|
|
113
|
+
Without a studio around it (the game opened as a plain file, in a Vite dev server, or a game.json without
|
|
114
|
+
`"saves": true`) the same calls keep everything in that page's own storage (`mode: 'local'`).
|
|
115
|
+
|
|
116
|
+
## Offline, versions and conflicts
|
|
117
|
+
|
|
118
|
+
The play page keeps a copy of the player's saves in that browser and an **outbox** of changes. A `set` while
|
|
119
|
+
offline is safe at once and syncs when the connection comes back (the `online` event, and retries that back off
|
|
120
|
+
to 30 s), in order: memorials first, then saves, then stats. Leaving the page sends what is left.
|
|
121
|
+
|
|
122
|
+
Every key has a **version** that goes up by one with each write. Every change names the version it was made from,
|
|
123
|
+
so a stale copy never overwrites newer progress silently: if another device saved the key in between, the change
|
|
124
|
+
is a **conflict**. By default the newer cloud copy wins and the game hears `conflict`; with `onConflict` the game
|
|
125
|
+
decides (merge two inventories, keep the higher level). This is also what stops a hardcore hero from coming back:
|
|
126
|
+
an old offline copy of a dead hero is a conflict with the wipe, and the wipe wins.
|
|
127
|
+
|
|
128
|
+
A write is atomic **per key**, not across keys: keep one character in one key.
|
|
129
|
+
|
|
130
|
+
## The hall of the fallen: a hardcore recipe
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
async function heroDied(cause: string) {
|
|
134
|
+
if (!hero.hardcore) { hero.gold = Math.floor(hero.gold * 0.9); await saves.set('hero', hero); return; }
|
|
135
|
+
const fallen = hero;
|
|
136
|
+
hero = null;
|
|
137
|
+
await saves.stats.add({ deaths: 1, heroesLost: 1 }); // lifetime: kept
|
|
138
|
+
await saves.fall({
|
|
139
|
+
character: fallen.name,
|
|
140
|
+
summary: { level: fallen.level, gold: fallen.gold, kills: fallen.kills, cause },
|
|
141
|
+
wipe: true, // the hero's save goes, in the same step
|
|
142
|
+
keep: ['settings'], // what survives (sound volume, key bindings)
|
|
143
|
+
});
|
|
144
|
+
showHall(await saves.fallen({ limit: 10 })); // the names of the dead, newest first
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- The death and the wipe are one transaction on the server; on the device the wipe happens at once, even offline.
|
|
149
|
+
- Memorials are public to the game's players: the character's name, the player's display name and the summary
|
|
150
|
+
(at most 2 KB). A player's own memorials go with their account if they delete it.
|
|
151
|
+
- At most 20 memorials a day per player and game.
|
|
152
|
+
|
|
153
|
+
## Limits
|
|
154
|
+
|
|
155
|
+
| | |
|
|
156
|
+
| --- | --- |
|
|
157
|
+
| A value | 64 KiB of JSON (1 MiB with the studio's storage, R2: kept under a random key that `/media/` never serves) |
|
|
158
|
+
| Keys | 64 per player per game; names of 1 to 64 letters, digits, `_ . : -` |
|
|
159
|
+
| A game's saves | 256 KiB per player in D1 (and 4 MiB of large ones with storage) |
|
|
160
|
+
| One write | 32 keys |
|
|
161
|
+
| Stats | 64 names per player per game, finite numbers |
|
|
162
|
+
| Passkeys | 10 per account |
|
|
163
|
+
| Rates (per Worker instance, in memory) | 120 writes and 240 reads a minute per player; 60 sign-in or sign-up tries per address per 10 minutes; 60 new players per address an hour |
|
|
164
|
+
| New players | 5,000 a day for the whole studio (the `PLAYER_LIMIT_DAILY` variable changes it) |
|
|
165
|
+
|
|
166
|
+
On Cloudflare's free plan a D1 database has 100,000 row writes a day for everything (stats included). A save is
|
|
167
|
+
one write; the play page coalesces a burst into one request per key. Save on change, not every frame.
|
|
168
|
+
|
|
169
|
+
## What is stored, and privacy
|
|
170
|
+
|
|
171
|
+
Migration `site/migrations/0004_players.sql` (deploy applies it; an older studio gets it from the next deploy):
|
|
172
|
+
|
|
173
|
+
| Table | What | Never |
|
|
174
|
+
| --- | --- | --- |
|
|
175
|
+
| `players` | a random id (`pl_…`), the display name, guest and owner flags, when made and last seen (refreshed at most every six hours) | an email, a password, an IP address |
|
|
176
|
+
| `player_passkeys` | each passkey's public key, algorithm, signature counter, a label ("iPhone"), when made and last used | a private key (it cannot leave the device) |
|
|
177
|
+
| `player_sessions` | the SHA-256 of each session cookie and short-lived room pass | the cookie itself |
|
|
178
|
+
| `player_challenges` | the SHA-256 of each one-time challenge or emailed link, for minutes | |
|
|
179
|
+
| `player_emails` | a recovery email, only if the player added one | |
|
|
180
|
+
| `saves`, `player_stats`, `memorials` | what the game saved | anything the game did not |
|
|
181
|
+
|
|
182
|
+
- **The player** sees all of it at `/account/`, downloads it as one JSON file (`/api/player/export`), and deletes
|
|
183
|
+
it all (`/api/player/delete`): their passkeys, sessions, saves, stats, memorials and email, and their large
|
|
184
|
+
saves in R2. The page tells them to remove the passkey from their device too, and tells the device itself where
|
|
185
|
+
the browser supports it.
|
|
186
|
+
- **The owner** sees counts (`homie-studio players`, the private stats page, `studio_stats`) and, through the back
|
|
187
|
+
office, names and when players were last seen: never a passkey, a session or an email.
|
|
188
|
+
- **No IP address is stored.** Rate limits count in memory and forget. No third-party script, beacon or cookie is
|
|
189
|
+
used; the passkey ceremony is between the player's device and this site.
|
|
190
|
+
- **Guests** nobody has played for 180 days are deleted with everything they kept.
|
|
191
|
+
- The session cookie is `studio_player`: HttpOnly, Secure, SameSite=Lax, this site only. The game, in its sandboxed
|
|
192
|
+
frame, never sees it: the play page makes every saves call for it, for that game only.
|
|
193
|
+
|
|
194
|
+
## For the studio's back office
|
|
195
|
+
|
|
196
|
+
Server-side, from the Worker (`import { players } from '@homie-rocks/studio/worker'`):
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
await players.count(env); // { accounts, guests, owners, new7d, active1d, active7d }
|
|
200
|
+
await players.list(env, { limit, cursor, q, guests }); // { players: [{ id, name, guest, owner, createdAt, seenAt, passkeys }], next }
|
|
201
|
+
await players.get(env, id); // the same, plus saves per game (keys, bytes, last write), stats, memorials
|
|
202
|
+
await players.of(request, env); // the request's signed-in player, or null
|
|
203
|
+
await players.isOwner(request, env); // the request is the owner's own account
|
|
204
|
+
await players.passFor(env, id, { game, minutes }); // a short-lived pass naming a player ('pp_…')
|
|
205
|
+
await players.readPass(env, pass); // { id, name, guest, owner, game } or null
|
|
206
|
+
await players.rename(env, id, name); // moderation: the same name rules
|
|
207
|
+
await players.remove(env, id); // everything of that player, as their own Delete does
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**The owner's own account**: the owner plays with an ordinary passkey account on their site, and marks it once:
|
|
211
|
+
`npx --no-install homie-studio players owner` (it uses the studio's own Cloudflare login, like `stats link`) gives
|
|
212
|
+
a one-time link; opened where they are signed in, one press marks that account as the owner's. `players owner
|
|
213
|
+
--revoke` unmarks every account.
|
|
214
|
+
|
|
215
|
+
**In a room**: the play page can ask `POST /api/player/pass { "game": "<id>" }` for a 10-minute pass; a room that
|
|
216
|
+
needs to know who a socket is (to recognise the owner, or to kick by account) takes it on the socket URL and calls
|
|
217
|
+
`players.readPass` before handing the socket to the Table.
|
|
218
|
+
|
|
219
|
+
## HTTP
|
|
220
|
+
|
|
221
|
+
Same origin only (a POST must come from this site's own pages, as JSON). For reference; games use the SDK.
|
|
222
|
+
|
|
223
|
+
| | |
|
|
224
|
+
| --- | --- |
|
|
225
|
+
| `GET /api/player/me` | the signed-in player, the features (`accounts`, `email`), the limits |
|
|
226
|
+
| `POST /api/player/signup/options`, `POST /api/player/signup` | make an account, or add a passkey to this browser's guest or account |
|
|
227
|
+
| `POST /api/player/signin/options`, `POST /api/player/signin` | sign in with a passkey (discoverable: no name typed) |
|
|
228
|
+
| `POST /api/player/signout`, `POST /api/player/name`, `GET /api/player/passkeys`, `POST /api/player/passkeys/remove` | |
|
|
229
|
+
| `GET /api/player/export`, `POST /api/player/delete` (`{ "confirm": "delete" }`) | everything, and nothing |
|
|
230
|
+
| `GET /api/player/saves/<game>[?values=1]`, `GET /api/player/saves/<game>/<key>` | read |
|
|
231
|
+
| `POST /api/player/saves/<game>` `{ set: [{ key, value, base }], del: [{ key, base }] }` | write (a guest is made on the first) |
|
|
232
|
+
| `POST /api/player/saves/<game>/wipe` `{ keep }` | |
|
|
233
|
+
| `GET`/`POST /api/player/stats/<game>` `{ add, max, min }` | lifetime stats |
|
|
234
|
+
| `GET /api/player/fallen/<game>[?mine=1]`, `POST /api/player/fallen/<game>` `{ character, summary, wipe, keep }` | the hall of the fallen |
|
|
235
|
+
| `POST /api/player/pass`, `POST /api/player/owner` | a room pass; the owner's one-time mark |
|
|
236
|
+
| `POST /api/player/email`, `/email/verify`, `/email/remove`, `/recover`, `/recover/options` | the recovery email, with a mail sender |
|
package/saves/saves.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* saves.ts — cloud saves for a game on a Homie studio (@homie-rocks/studio/saves). SAVES.md beside it is the guide.
|
|
3
|
+
* =============================================================================
|
|
4
|
+
*
|
|
5
|
+
* import { createSaves } from '@homie-rocks/studio/saves';
|
|
6
|
+
* const saves = createSaves({ game: 'my-game' });
|
|
7
|
+
* const hero = (await saves.get<Hero>('hero')) ?? newHero();
|
|
8
|
+
* await saves.set('hero', hero); // safe in this browser at once, in the cloud when online
|
|
9
|
+
* saves.stats.add({ kills: 1, seconds: 30 }); // lifetime numbers that survive a wipe
|
|
10
|
+
* await saves.fall({ character: hero.name, summary: { level: hero.level }, wipe: true }); // hardcore death
|
|
11
|
+
* saves.on('player', () => reload()); // the player signed in on this device: their saves changed
|
|
12
|
+
*
|
|
13
|
+
* WHERE IT KEEPS THINGS. A game on a studio site runs in a sandboxed frame that can keep nothing of its own. The
|
|
14
|
+
* play shell around it (on the studio's own origin) does it: a local copy in that browser, an outbox that syncs to
|
|
15
|
+
* the studio's Worker and D1, and the player's passkey sign-in. This file only talks to the shell (postMessage),
|
|
16
|
+
* so the game never sees a session or a cookie, and can only reach its OWN game's saves. Without a shell (the
|
|
17
|
+
* game opened as a plain file, a Vite dev server, a game.json with no "saves": true) it keeps everything in this
|
|
18
|
+
* page's localStorage instead (mode 'local'), so the same code runs everywhere.
|
|
19
|
+
*
|
|
20
|
+
* ROOMS ARE NOT SAVES. A room forgets everything 60 s after its last player leaves. A character that must last
|
|
21
|
+
* days lives in saves: load it when the player arrives, save it when it changes, and let the room carry only the
|
|
22
|
+
* live round.
|
|
23
|
+
*
|
|
24
|
+
* It has no imports and uses only erasable TypeScript: vendor it into a game like netplay.ts, or import it.
|
|
25
|
+
* =============================================================================
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const SAVES_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
export interface Player {
|
|
31
|
+
/** `pl_…` on a studio site (null before this browser saved anything, and in local mode). */
|
|
32
|
+
id: string | null;
|
|
33
|
+
name: string;
|
|
34
|
+
/** No passkey yet: progress is kept in this browser only until the player makes an account. */
|
|
35
|
+
guest: boolean;
|
|
36
|
+
/** The studio owner's own account (marked once with `homie-studio players owner`). Show, never trust. */
|
|
37
|
+
owner: boolean;
|
|
38
|
+
signedIn: boolean;
|
|
39
|
+
/** No studio shell around the game: everything is in this page's localStorage. */
|
|
40
|
+
local: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SaveEntry { key: string; version: number; updatedAt: number | null; pending?: boolean }
|
|
44
|
+
export interface SetResult { ok: boolean; synced: boolean; version: number; error?: string; message?: string }
|
|
45
|
+
export interface Memorial { character: string; player: string; summary: Record<string, unknown>; at: number }
|
|
46
|
+
export interface SavesStatus { mode: 'cloud' | 'local'; online: boolean; pending: number; lastSyncAt: number | null; lastError: string | null }
|
|
47
|
+
export interface Conflict<T = unknown> { key: string; mine: T | null; theirs: T | null; theirsVersion: number }
|
|
48
|
+
export type SavesEvent = 'player' | 'synced' | 'conflict' | 'status' | 'error';
|
|
49
|
+
|
|
50
|
+
export interface SavesOptions {
|
|
51
|
+
/** The game's id (its folder): names the local-mode store. On a studio site the shell's own game id is used. */
|
|
52
|
+
game?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Another device saved a key since this one's copy (this browser was offline, or two devices played at once).
|
|
55
|
+
* Return the value to keep (merge them, or return `mine`), or `undefined` for the cloud's (the default: a stale
|
|
56
|
+
* offline copy never overwrites newer progress, so a hardcore character cannot come back from the dead).
|
|
57
|
+
*/
|
|
58
|
+
onConflict?: (c: Conflict) => unknown | Promise<unknown>;
|
|
59
|
+
/** Tests and custom shells: where messages go (default: the parent frame) and the shell's answer window. */
|
|
60
|
+
target?: { postMessage: (m: unknown, origin: string) => void } | null;
|
|
61
|
+
/** Force local mode (true) or cloud mode (false). Default: cloud when the shell said so (HOMIE_NET.saves). */
|
|
62
|
+
local?: boolean;
|
|
63
|
+
/** Local mode's storage (default: localStorage when usable, else memory). */
|
|
64
|
+
storage?: { getItem(k: string): string | null; setItem(k: string, v: string): void } | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Saves {
|
|
68
|
+
/** Resolves once the player and their saves are loaded (cloud mode: from the shell's copy, then the cloud). */
|
|
69
|
+
readonly ready: Promise<Player>;
|
|
70
|
+
readonly player: Player;
|
|
71
|
+
readonly mode: 'cloud' | 'local';
|
|
72
|
+
/** The last known value of a key, at once (for a render loop). `get` is the same, as a promise. */
|
|
73
|
+
peek<T = unknown>(key: string): T | null;
|
|
74
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
75
|
+
/** Keep a value (JSON, 64 KiB; 1 MiB with the studio's storage). Resolves when synced, or after ~4 s saved locally. */
|
|
76
|
+
set<T = unknown>(key: string, value: T): Promise<SetResult>;
|
|
77
|
+
remove(key: string): Promise<SetResult>;
|
|
78
|
+
list(): Promise<SaveEntry[]>;
|
|
79
|
+
/** Delete this game's saves (except `keep`): "new game". Lifetime stats and memorials stay. */
|
|
80
|
+
wipe(opts?: { keep?: string[] }): Promise<{ ok: boolean; synced: boolean }>;
|
|
81
|
+
readonly stats: {
|
|
82
|
+
get(): Promise<Record<string, number>>;
|
|
83
|
+
/** Add to counters: { kills: 1, gold: 25, seconds: 30 }. */
|
|
84
|
+
add(values: Record<string, number>): Promise<{ ok: boolean }>;
|
|
85
|
+
/** Keep the highest: { level: 12 }. */
|
|
86
|
+
max(values: Record<string, number>): Promise<{ ok: boolean }>;
|
|
87
|
+
/** Keep the lowest: { fastestRun: 312 }. */
|
|
88
|
+
min(values: Record<string, number>): Promise<{ ok: boolean }>;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* A character died for good (hardcore): a memorial in the hall of the fallen (its name, your display name, a
|
|
92
|
+
* small summary), and with `wipe: true` this game's saves are deleted in the same step (except `keep`).
|
|
93
|
+
*/
|
|
94
|
+
fall(m: { character: string; summary?: Record<string, unknown>; wipe?: boolean; keep?: string[] }): Promise<{ ok: boolean; synced: boolean; memorial: Memorial | null }>;
|
|
95
|
+
/** The hall of the fallen: the newest memorials of this game (everyone's), or `mine: true`. */
|
|
96
|
+
fallen(opts?: { limit?: number; mine?: boolean }): Promise<Memorial[]>;
|
|
97
|
+
/** Change the player's display name on this studio (the same rules as the account page). */
|
|
98
|
+
rename(name: string): Promise<{ ok: boolean; name?: string; message?: string }>;
|
|
99
|
+
/** Ask the shell to show its sign-in sheet (a passkey). Call it from a button the player pressed. */
|
|
100
|
+
signIn(reason?: string): void;
|
|
101
|
+
/** Wait until every change is in the cloud (or the network said no). */
|
|
102
|
+
flush(): Promise<SavesStatus>;
|
|
103
|
+
status(): SavesStatus;
|
|
104
|
+
on(ev: 'player', fn: (p: Player) => void): () => void;
|
|
105
|
+
on(ev: 'conflict', fn: (c: Conflict & { kept: 'theirs' | 'game' }) => void): () => void;
|
|
106
|
+
on(ev: 'synced' | 'status', fn: (s: SavesStatus) => void): () => void;
|
|
107
|
+
on(ev: 'error', fn: (e: { key?: string; error: string; message?: string }) => void): () => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
111
|
+
type Msg = Record<string, unknown> & { t?: string; q?: number; ev?: string; ok?: boolean };
|
|
112
|
+
|
|
113
|
+
export function createSaves(opts: SavesOptions = {}): Saves {
|
|
114
|
+
const g = globalThis as unknown as {
|
|
115
|
+
HOMIE_NET?: { saves?: boolean }; parent?: unknown; addEventListener?: typeof addEventListener; localStorage?: Storage;
|
|
116
|
+
};
|
|
117
|
+
const cloud = opts.local === undefined ? Boolean(g.HOMIE_NET?.saves) : !opts.local;
|
|
118
|
+
const game = String(opts.game ?? 'game').replace(/[^a-z0-9-]/g, '').slice(0, 40) || 'game';
|
|
119
|
+
const listeners = new Map<string, Set<(x: unknown) => void>>();
|
|
120
|
+
const emit = (ev: string, x: unknown): void => { for (const fn of listeners.get(ev) ?? []) { try { fn(x); } catch { /* the game's */ } } };
|
|
121
|
+
let player: Player = { id: null, name: 'Guest', guest: true, owner: false, signedIn: false, local: !cloud };
|
|
122
|
+
let mirror: Record<string, unknown> = {};
|
|
123
|
+
let st: SavesStatus = { mode: cloud ? 'cloud' : 'local', online: true, pending: 0, lastSyncAt: null, lastError: null };
|
|
124
|
+
|
|
125
|
+
/* ------------------------------------------------------------ cloud mode: the shell */
|
|
126
|
+
const target = opts.target !== undefined ? opts.target : (g.parent && g.parent !== (globalThis as unknown) ? g.parent as { postMessage: (m: unknown, o: string) => void } : null);
|
|
127
|
+
let seq = 0;
|
|
128
|
+
const waiting = new Map<number, (m: Msg) => void>();
|
|
129
|
+
const ask = (op: string, args: Record<string, unknown> = {}, timeoutMs = 20_000): Promise<Msg> => new Promise((resolve) => {
|
|
130
|
+
if (!target) { resolve({ ok: false, error: 'no-shell', message: 'no studio shell around this game' }); return; }
|
|
131
|
+
const q = ++seq;
|
|
132
|
+
const timer = setTimeout(() => { waiting.delete(q); resolve({ ok: false, error: 'timeout', message: 'the play shell did not answer' }); }, timeoutMs);
|
|
133
|
+
waiting.set(q, (m) => { clearTimeout(timer); resolve(m); });
|
|
134
|
+
try { target.postMessage({ t: 'homie-save', q, op, ...args }, '*'); } catch { clearTimeout(timer); waiting.delete(q); resolve({ ok: false, error: 'no-shell' }); }
|
|
135
|
+
});
|
|
136
|
+
const asPlayer = (p: unknown): Player => {
|
|
137
|
+
const o = (p ?? {}) as Partial<Player>;
|
|
138
|
+
return { id: typeof o.id === 'string' ? o.id : null, name: typeof o.name === 'string' ? o.name : 'Guest', guest: o.guest !== false, owner: o.owner === true, signedIn: o.guest === false, local: false };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (cloud && g.addEventListener) {
|
|
142
|
+
g.addEventListener('message', (ev: MessageEvent) => {
|
|
143
|
+
const m = ev.data as Msg;
|
|
144
|
+
if (!m || typeof m !== 'object' || m.t !== 'homie-save') return;
|
|
145
|
+
if (target && ev.source && ev.source !== (target as unknown)) return;
|
|
146
|
+
if (typeof m.q === 'number' && waiting.has(m.q)) { const f = waiting.get(m.q)!; waiting.delete(m.q); f(m); return; }
|
|
147
|
+
if (m.ev === 'player') {
|
|
148
|
+
player = asPlayer(m.player);
|
|
149
|
+
if (m.cache && typeof m.cache === 'object') mirror = { ...(m.cache as Record<string, unknown>) };
|
|
150
|
+
emit('player', player);
|
|
151
|
+
} else if (m.ev === 'status' || m.ev === 'synced') {
|
|
152
|
+
if (m.status) st = { ...(m.status as SavesStatus) };
|
|
153
|
+
emit(m.ev, st);
|
|
154
|
+
} else if (m.ev === 'error') emit('error', { key: m.key, error: m.error, message: m.message });
|
|
155
|
+
else if (m.ev === 'conflict') {
|
|
156
|
+
const c = { key: String(m.key), mine: m.mine ?? null, theirs: m.theirs ?? null, theirsVersion: Number(m.theirsVersion) || 0 };
|
|
157
|
+
if (typeof m.ask === 'number' && opts.onConflict) {
|
|
158
|
+
Promise.resolve().then(() => opts.onConflict!(c)).then((keep) => {
|
|
159
|
+
target?.postMessage({ t: 'homie-save', op: 'resolve', ask: m.ask, ...(keep === undefined ? { keep: 'theirs' } : { keep: 'value', value: keep }) }, '*');
|
|
160
|
+
}, () => target?.postMessage({ t: 'homie-save', op: 'resolve', ask: m.ask, keep: 'theirs' }, '*'));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (m.kept === 'theirs') { if (c.theirs === null) delete mirror[c.key]; else mirror[c.key] = c.theirs; }
|
|
164
|
+
emit('conflict', { ...c, kept: m.kept === 'game' ? 'game' : 'theirs' });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ------------------------------------------------------------ local mode: this page's storage */
|
|
170
|
+
const memory = new Map<string, string>();
|
|
171
|
+
const store = opts.storage !== undefined && opts.storage !== null ? opts.storage : (() => {
|
|
172
|
+
try { const ls = g.localStorage; if (ls) { ls.setItem('homie-saves.test', '1'); return ls; } } catch { /* sandboxed */ }
|
|
173
|
+
return { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); } };
|
|
174
|
+
})();
|
|
175
|
+
interface LocalDb { saves: Record<string, { v: unknown; ver: number; at: number }>; stats: Record<string, number>; fallen: Memorial[] }
|
|
176
|
+
const LOCAL = `homie-saves.local.${game}`;
|
|
177
|
+
const readLocal = (): LocalDb => { try { const j = JSON.parse(store.getItem(LOCAL) ?? 'null'); if (j && j.saves) return j; } catch { /* fresh */ } return { saves: {}, stats: {}, fallen: [] }; };
|
|
178
|
+
const writeLocal = (db: LocalDb): void => { try { store.setItem(LOCAL, JSON.stringify(db)); } catch { /* full */ } };
|
|
179
|
+
|
|
180
|
+
/* ------------------------------------------------------------ ready */
|
|
181
|
+
const ready: Promise<Player> = (async () => {
|
|
182
|
+
if (!cloud || !target) {
|
|
183
|
+
player = { id: null, name: 'You', guest: true, owner: false, signedIn: false, local: true };
|
|
184
|
+
st = { ...st, mode: 'local' };
|
|
185
|
+
const db = readLocal();
|
|
186
|
+
mirror = Object.fromEntries(Object.entries(db.saves).map(([k, e]) => [k, e.v]));
|
|
187
|
+
return player;
|
|
188
|
+
}
|
|
189
|
+
const r = await ask('hello', { v: SAVES_VERSION, resolver: Boolean(opts.onConflict) }, 15_000);
|
|
190
|
+
if (r.ok) {
|
|
191
|
+
player = asPlayer(r.player);
|
|
192
|
+
mirror = { ...((r.cache as Record<string, unknown>) ?? {}) };
|
|
193
|
+
if (r.status) st = { ...(r.status as SavesStatus) };
|
|
194
|
+
} else st = { ...st, online: false, lastError: String(r.message ?? r.error ?? 'no shell') };
|
|
195
|
+
return player;
|
|
196
|
+
})();
|
|
197
|
+
|
|
198
|
+
const checkKey = (key: string): string | null => (KEY.test(String(key)) ? null : 'a save key is 1 to 64 letters, digits, _ . : or -');
|
|
199
|
+
|
|
200
|
+
const api: Saves = {
|
|
201
|
+
ready,
|
|
202
|
+
get player() { return player; },
|
|
203
|
+
get mode() { return cloud && target ? 'cloud' : 'local'; },
|
|
204
|
+
peek<T>(key: string): T | null { return (key in mirror ? mirror[key] : null) as T | null; },
|
|
205
|
+
async get<T>(key: string): Promise<T | null> {
|
|
206
|
+
await ready;
|
|
207
|
+
if (checkKey(key)) return null;
|
|
208
|
+
if (api.mode === 'local') return (readLocal().saves[key]?.v ?? null) as T | null;
|
|
209
|
+
const r = await ask('get', { key });
|
|
210
|
+
if (r.ok) { if (r.value === null || r.value === undefined) delete mirror[key]; else mirror[key] = r.value; }
|
|
211
|
+
return (r.ok ? r.value ?? null : mirror[key] ?? null) as T | null;
|
|
212
|
+
},
|
|
213
|
+
async set<T>(key: string, value: T): Promise<SetResult> {
|
|
214
|
+
await ready;
|
|
215
|
+
const bad = checkKey(key);
|
|
216
|
+
if (bad) return { ok: false, synced: false, version: 0, error: 'key', message: bad };
|
|
217
|
+
if (value === undefined) return { ok: false, synced: false, version: 0, error: 'value', message: 'a save needs a value; use remove() to delete a key' };
|
|
218
|
+
let copy: unknown;
|
|
219
|
+
try { copy = JSON.parse(JSON.stringify(value)); } catch { return { ok: false, synced: false, version: 0, error: 'value', message: 'a save is JSON' }; }
|
|
220
|
+
mirror[key] = copy;
|
|
221
|
+
if (api.mode === 'local') {
|
|
222
|
+
const db = readLocal();
|
|
223
|
+
const ver = (db.saves[key]?.ver ?? 0) + 1;
|
|
224
|
+
db.saves[key] = { v: copy, ver, at: Date.now() };
|
|
225
|
+
writeLocal(db);
|
|
226
|
+
return { ok: true, synced: true, version: ver };
|
|
227
|
+
}
|
|
228
|
+
const r = await ask('set', { key, value: copy });
|
|
229
|
+
return { ok: Boolean(r.ok), synced: Boolean(r.synced), version: Number(r.version) || 0, ...(r.ok ? {} : { error: String(r.error), message: String(r.message ?? '') }) };
|
|
230
|
+
},
|
|
231
|
+
async remove(key: string): Promise<SetResult> {
|
|
232
|
+
await ready;
|
|
233
|
+
const bad = checkKey(key);
|
|
234
|
+
if (bad) return { ok: false, synced: false, version: 0, error: 'key', message: bad };
|
|
235
|
+
delete mirror[key];
|
|
236
|
+
if (api.mode === 'local') { const db = readLocal(); delete db.saves[key]; writeLocal(db); return { ok: true, synced: true, version: 0 }; }
|
|
237
|
+
const r = await ask('del', { key });
|
|
238
|
+
return { ok: Boolean(r.ok), synced: Boolean(r.synced), version: 0, ...(r.ok ? {} : { error: String(r.error), message: String(r.message ?? '') }) };
|
|
239
|
+
},
|
|
240
|
+
async list(): Promise<SaveEntry[]> {
|
|
241
|
+
await ready;
|
|
242
|
+
if (api.mode === 'local') return Object.entries(readLocal().saves).sort(([a], [b]) => a.localeCompare(b)).map(([key, e]) => ({ key, version: e.ver, updatedAt: e.at }));
|
|
243
|
+
const r = await ask('list');
|
|
244
|
+
return Array.isArray(r.keys) ? r.keys as SaveEntry[] : [];
|
|
245
|
+
},
|
|
246
|
+
async wipe({ keep = [] } = {}) {
|
|
247
|
+
await ready;
|
|
248
|
+
for (const k of Object.keys(mirror)) if (!keep.includes(k)) delete mirror[k];
|
|
249
|
+
if (api.mode === 'local') { const db = readLocal(); for (const k of Object.keys(db.saves)) if (!keep.includes(k)) delete db.saves[k]; writeLocal(db); return { ok: true, synced: true }; }
|
|
250
|
+
const r = await ask('wipe', { keep });
|
|
251
|
+
return { ok: Boolean(r.ok), synced: Boolean(r.synced) };
|
|
252
|
+
},
|
|
253
|
+
stats: {
|
|
254
|
+
async get() {
|
|
255
|
+
await ready;
|
|
256
|
+
if (api.mode === 'local') return { ...readLocal().stats };
|
|
257
|
+
const r = await ask('stats', { kind: 'get' });
|
|
258
|
+
return (r.stats as Record<string, number>) ?? {};
|
|
259
|
+
},
|
|
260
|
+
add: (values) => bump('add', values),
|
|
261
|
+
max: (values) => bump('max', values),
|
|
262
|
+
min: (values) => bump('min', values),
|
|
263
|
+
},
|
|
264
|
+
async fall({ character, summary = {}, wipe = false, keep = [] }) {
|
|
265
|
+
await ready;
|
|
266
|
+
if (wipe) for (const k of Object.keys(mirror)) if (!keep.includes(k)) delete mirror[k];
|
|
267
|
+
if (api.mode === 'local') {
|
|
268
|
+
const db = readLocal();
|
|
269
|
+
const memorial: Memorial = { character: String(character).slice(0, 32), player: player.name, summary, at: Date.now() };
|
|
270
|
+
db.fallen.unshift(memorial);
|
|
271
|
+
db.fallen = db.fallen.slice(0, 50);
|
|
272
|
+
if (wipe) for (const k of Object.keys(db.saves)) if (!keep.includes(k)) delete db.saves[k];
|
|
273
|
+
writeLocal(db);
|
|
274
|
+
return { ok: true, synced: true, memorial };
|
|
275
|
+
}
|
|
276
|
+
const r = await ask('fall', { character, summary, wipe, keep });
|
|
277
|
+
return { ok: Boolean(r.ok), synced: Boolean(r.synced), memorial: (r.memorial as Memorial) ?? null };
|
|
278
|
+
},
|
|
279
|
+
async fallen({ limit = 20, mine = false } = {}) {
|
|
280
|
+
await ready;
|
|
281
|
+
if (api.mode === 'local') return readLocal().fallen.slice(0, limit);
|
|
282
|
+
const r = await ask('fallen', { limit, mine });
|
|
283
|
+
return Array.isArray(r.fallen) ? r.fallen as Memorial[] : [];
|
|
284
|
+
},
|
|
285
|
+
async rename(name: string) {
|
|
286
|
+
await ready;
|
|
287
|
+
if (api.mode === 'local') { player = { ...player, name: String(name).slice(0, 24) }; emit('player', player); return { ok: true, name: player.name }; }
|
|
288
|
+
const r = await ask('rename', { name });
|
|
289
|
+
return { ok: Boolean(r.ok), ...(r.name ? { name: String(r.name) } : {}), ...(r.message ? { message: String(r.message) } : {}) };
|
|
290
|
+
},
|
|
291
|
+
signIn(reason?: string) { if (api.mode === 'cloud') void ask('signin', reason ? { reason } : {}); },
|
|
292
|
+
async flush() {
|
|
293
|
+
await ready;
|
|
294
|
+
if (api.mode === 'local') return st;
|
|
295
|
+
const r = await ask('flush', {}, 30_000);
|
|
296
|
+
if (r.status) st = { ...(r.status as SavesStatus) };
|
|
297
|
+
return st;
|
|
298
|
+
},
|
|
299
|
+
status() { return st; },
|
|
300
|
+
on(ev: SavesEvent, fn: (x: never) => void) {
|
|
301
|
+
const set = listeners.get(ev) ?? new Set();
|
|
302
|
+
set.add(fn as (x: unknown) => void);
|
|
303
|
+
listeners.set(ev, set);
|
|
304
|
+
return () => { set.delete(fn as (x: unknown) => void); };
|
|
305
|
+
},
|
|
306
|
+
} as Saves;
|
|
307
|
+
|
|
308
|
+
async function bump(kind: 'add' | 'max' | 'min', values: Record<string, number>): Promise<{ ok: boolean }> {
|
|
309
|
+
await ready;
|
|
310
|
+
if (api.mode === 'local') {
|
|
311
|
+
const db = readLocal();
|
|
312
|
+
for (const [k, raw] of Object.entries(values ?? {})) {
|
|
313
|
+
const n = Number(raw);
|
|
314
|
+
if (!Number.isFinite(n)) continue;
|
|
315
|
+
const cur = db.stats[k];
|
|
316
|
+
db.stats[k] = cur === undefined ? n : kind === 'add' ? cur + n : kind === 'max' ? Math.max(cur, n) : Math.min(cur, n);
|
|
317
|
+
}
|
|
318
|
+
writeLocal(db);
|
|
319
|
+
return { ok: true };
|
|
320
|
+
}
|
|
321
|
+
const r = await ask('stats', { kind, values });
|
|
322
|
+
return { ok: Boolean(r.ok) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return api;
|
|
326
|
+
}
|
package/site/SITE.md
CHANGED
|
@@ -156,6 +156,15 @@ The game runs in a sandboxed frame with an opaque origin, which is why its allow
|
|
|
156
156
|
(`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
|
|
157
157
|
SecurityError), so a controller would not work.
|
|
158
158
|
|
|
159
|
+
## Player accounts: `/account/`
|
|
160
|
+
|
|
161
|
+
A studio whose games keep saves (game.json `"saves": true`, [../saves/SAVES.md](../saves/SAVES.md)) has a player
|
|
162
|
+
account page at `/account/`, in the studio's look: make an account with a passkey, sign in on another device,
|
|
163
|
+
choose a name, add or remove passkeys, a recovery email (only with a mail sender), download everything, delete
|
|
164
|
+
everything. The play page of such a game carries the saves bridge and a "who is playing" row in its room sheet,
|
|
165
|
+
and shows a passkey sheet over the game when the game asks (`saves.signIn()`); its landing links the account page.
|
|
166
|
+
`/account/` belongs to the site: `site/pages/account/` and `site/public/account/` are refused.
|
|
167
|
+
|
|
159
168
|
## The arcade knock
|
|
160
169
|
|
|
161
170
|
A game made with Homie's arcade controls asks its page's origin for a Homie box (`GET /__homie/call`), and a score
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "ember-vale",
|
|
3
|
+
"name": "Ember Vale",
|
|
4
|
+
"blurb": "A hero who lasts for days. Hunt slimes with whoever is here tonight; your level, gold and name follow you to every device. Go hardcore and a death is forever.",
|
|
5
|
+
"players": { "min": 1, "max": 8 },
|
|
6
|
+
"roundSeconds": 90,
|
|
7
|
+
"entry": "src/main.ts",
|
|
8
|
+
"netplay": { "v": 1, "public": true, "movement": "owner" },
|
|
9
|
+
"saves": true,
|
|
10
|
+
"landing": {
|
|
11
|
+
"pitch": "Your hero keeps every level. The night does not.",
|
|
12
|
+
"howToPlay": [
|
|
13
|
+
"Make a hero, or pick up the one you left.",
|
|
14
|
+
"Strike slimes for experience and gold; the King Slime pays best.",
|
|
15
|
+
"Every night ends with a ranking; your hero keeps what it earned.",
|
|
16
|
+
"Hardcore: one life. Fall and you join the Hall of the Fallen."
|
|
17
|
+
],
|
|
18
|
+
"controls": { "phone": "Drag on the left to move, STRIKE on the right.", "computer": "WASD or arrows to move, Space to strike." }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
|
6
|
+
<title>Ember Vale</title>
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<style>
|
|
9
|
+
html, body { margin: 0; height: 100%; background: #0b0f0a; overflow: hidden; overscroll-behavior: none; -webkit-user-select: none; user-select: none; font: 15px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #f3ead2; }
|
|
10
|
+
canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
|
|
11
|
+
.ui { position: fixed; z-index: 3; }
|
|
12
|
+
.pill { left: calc(12px + env(safe-area-inset-left)); top: calc(86px + env(safe-area-inset-top)); display: flex; gap: 6px; }
|
|
13
|
+
.pill button { min-height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid rgba(243,234,210,.25); background: rgba(10,14,9,.6); color: inherit; font: 600 12.5px/1 inherit; font-family: inherit; cursor: pointer; backdrop-filter: blur(4px); }
|
|
14
|
+
.panel { left: 50%; top: 50%; transform: translate(-50%, -50%); box-sizing: border-box; width: min(360px, calc(100vw - 24px)); padding: 18px; border-radius: 16px; background: rgba(14,19,12,.94); border: 1px solid rgba(243,234,210,.2); box-shadow: 0 20px 60px rgba(0,0,0,.55); -webkit-user-select: text; user-select: text; }
|
|
15
|
+
.panel h2 { margin: 0 0 6px; font-size: 20px; color: #ffcf6e; }
|
|
16
|
+
.panel p { margin: 0 0 12px; color: #c9c0a6; }
|
|
17
|
+
.panel input[type=text] { box-sizing: border-box; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 10px; border: 1px solid rgba(243,234,210,.25); background: #070a06; color: #fff; font: 16px inherit; font-family: inherit; }
|
|
18
|
+
.panel label.hc { display: flex; gap: 8px; align-items: center; margin: 12px 0; color: #e7d9b4; }
|
|
19
|
+
.panel .go { width: 100%; min-height: 46px; border: 0; border-radius: 12px; background: #ffcf6e; color: #1b1405; font: 800 16px inherit; font-family: inherit; cursor: pointer; }
|
|
20
|
+
.panel ol { margin: 0 0 12px; padding-left: 20px; max-height: 40vh; overflow: auto; }
|
|
21
|
+
.panel li { margin: 4px 0; color: #e7d9b4; }
|
|
22
|
+
.panel li small { color: #a39a80; }
|
|
23
|
+
.panel .close { float: right; background: none; border: 0; color: #c9c0a6; font-size: 20px; cursor: pointer; }
|
|
24
|
+
[hidden] { display: none !important; }
|
|
25
|
+
</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<canvas id="c"></canvas>
|
|
29
|
+
<div class="ui pill"><button type="button" id="who"></button><button type="button" id="hall-btn">Hall of the Fallen</button></div>
|
|
30
|
+
<div class="ui panel" id="make" hidden>
|
|
31
|
+
<h2>A new hero</h2>
|
|
32
|
+
<p>Your hero keeps every level and coin, on every device you play on.</p>
|
|
33
|
+
<input type="text" id="hero-name" maxlength="20" placeholder="Name your hero" autocomplete="off">
|
|
34
|
+
<label class="hc"><input type="checkbox" id="hero-hc"> Hardcore: one life. A death is forever, and the Hall remembers.</label>
|
|
35
|
+
<button type="button" class="go" id="hero-go">Into the vale</button>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="ui panel" id="hall" hidden>
|
|
38
|
+
<button type="button" class="close" id="hall-close" aria-label="Close">×</button>
|
|
39
|
+
<h2 id="hall-title">Hall of the Fallen</h2>
|
|
40
|
+
<p id="hall-text">Hardcore heroes who fell in Ember Vale.</p>
|
|
41
|
+
<ol id="hall-list"></ol>
|
|
42
|
+
<button type="button" class="go" id="hall-go" hidden>Make a new hero</button>
|
|
43
|
+
</div>
|
|
44
|
+
<script type="module" crossorigin src="./assets/main.js"></script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|