@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.10
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/CHANGELOG.md +160 -6
- package/README.md +27 -15
- package/dist/auto.d.ts +8 -8
- package/dist/auto.js +3 -3
- package/dist/avatar-pixi.d.ts +165 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +299 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +500 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +71 -10
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +3 -3
- package/dist/input.d.ts +2 -2
- package/dist/input.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/rooms.js +1 -1
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1946 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +138 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +123 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +18 -4
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +145 -19
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +138 -25
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +109 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +1 -39
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1122 -46
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +74 -14
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +179 -25
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +23 -4
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +90 -9
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +4563 -667
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +2354 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +174 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +2 -2
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +61 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +183 -0
- package/dist/turns.js.map +1 -0
- package/package.json +39 -3
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +329 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +67 -0
- package/src/avatar/art/hair.ts +254 -0
- package/src/avatar/art/headwear.ts +75 -0
- package/src/avatar/art/index.ts +21 -0
- package/src/avatar/art/shape.ts +87 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +246 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +79 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +173 -0
- package/src/avatar/layout.ts +47 -0
- package/src/avatar/look-like.ts +52 -0
- package/src/avatar/outputs/bitmap.ts +75 -0
- package/src/avatar/outputs/canvas-paint.ts +177 -0
- package/src/avatar/outputs/canvas.ts +62 -0
- package/src/avatar/outputs/custom-element.ts +140 -0
- package/src/avatar/outputs/element.ts +58 -0
- package/src/avatar/outputs/live-canvas.ts +57 -0
- package/src/avatar/outputs/overlay-svg.ts +22 -0
- package/src/avatar/outputs/pose-key.ts +30 -0
- package/src/avatar/outputs/svg-markup.ts +113 -0
- package/src/avatar/outputs/svg.ts +41 -0
- package/src/avatar/overlays.ts +128 -0
- package/src/avatar/render/body.ts +116 -0
- package/src/avatar/render/colours.ts +81 -0
- package/src/avatar/render/compose.ts +320 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +118 -0
- package/src/avatar/render/frame.ts +73 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +93 -0
- package/src/avatar/render/walker-parts.ts +121 -0
- package/src/avatar/render/walker.ts +199 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +63 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +201 -92
- package/src/core/errors.ts +15 -2
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +94 -30
- package/src/core/hub.ts +228 -7
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +52 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +66 -23
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +4 -49
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/page.ts +14 -1
- package/src/core/rest/rest-client.ts +14 -3
- package/src/core/rest/routes.gen.ts +144 -88
- package/src/core/resume/hub-resume.ts +286 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/storage/json-guards.ts +6 -0
- package/src/core/turns/hub-turns.ts +161 -0
- package/src/core/turns/turn-game.ts +510 -0
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +127 -12
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +49 -11
- package/src/doctor/checks.ts +12 -4
- package/src/doctor/cli.ts +5 -2
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/doctor/world-check.ts +61 -0
- package/src/index.ts +20 -2
- package/src/input/focus-ring.ts +13 -0
- package/src/input/input-registry.ts +9 -2
- package/src/input/input-system.ts +14 -1
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +13 -4
- package/src/input/navigator.ts +11 -0
- package/src/input/zero-config.ts +1 -14
- package/src/input.ts +4 -0
- package/src/mp.ts +1 -1
- package/src/overlay/overlay.ts +30 -4
- package/src/overlay/styles.ts +4 -3
- package/src/realtime/bridge.ts +5 -0
- package/src/realtime/multiplayer-forwarder.ts +13 -1
- package/src/realtime/multiplayer-types.ts +15 -1
- package/src/realtime/room-types.ts +28 -2
- package/src/realtime/rooms-forwarder.ts +27 -2
- package/src/realtime/runtime-api.ts +33 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/types.ts +1 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +1 -1
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
package/dist/turns.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { c as parseJsonText } from "./shared/json-guards.js";
|
|
2
|
+
import { createTurnRng, defineTurnRules, turnRngStateFromSeed } from "./turns-rules.js";
|
|
3
|
+
import { createTurnRng as createTurnRng$1, turnRngStateFromSeed as turnRngStateFromSeed$1 } from "@diffenderfer-games/hub-contract/turns-rules";
|
|
4
|
+
//#region src/turns-kit/check-rules.ts
|
|
5
|
+
/**
|
|
6
|
+
* The rules conformance kit (docs/multiplayer.md "Turn games: testing"):
|
|
7
|
+
* `checkTurnRules(rules, options)` plays many seeded games through a rules
|
|
8
|
+
* module the way the hub does and throws on the first broken promise:
|
|
9
|
+
*
|
|
10
|
+
* - every game reaches an outcome within `maxMoves`, and `toAct` is never
|
|
11
|
+
* empty before it; every seat in `toAct` gets to move (not only the
|
|
12
|
+
* first), and so do the seats in `mayAct` (out of turn: a jump-in, a
|
|
13
|
+
* catch) whose `autoReact` (or `options.reactMove`) has a move;
|
|
14
|
+
* - `toAct` and `mayAct` only name seats that exist;
|
|
15
|
+
* - the state survives a JSON round trip after every move, and the same seed
|
|
16
|
+
* plays the same game (no `Math.random`, no hidden globals);
|
|
17
|
+
* - `autoMove` and `autoReact` (when the rules have them) are always legal;
|
|
18
|
+
* - no seat's `view` or `eventView` shows another seat's secrets: markers
|
|
19
|
+
* planted with `options.plant` never appear in anyone else's view until
|
|
20
|
+
* the rules reveal them (`options.revealed`: a card played face up);
|
|
21
|
+
* - with `options.shuffleHidden` (the "twin table"): shuffling everything a
|
|
22
|
+
* seat may not see never changes that seat's view;
|
|
23
|
+
* - stored fixtures from older versions `migrate` to a state that views;
|
|
24
|
+
* - views, events and moves stay within the hub's size caps.
|
|
25
|
+
*
|
|
26
|
+
* It needs no hub: run it in a game's own unit tests (vitest, node:test).
|
|
27
|
+
*/
|
|
28
|
+
/** The hub's caps. */
|
|
29
|
+
const STATE_MAX_BYTES = 65536;
|
|
30
|
+
const MOVE_MAX_BYTES = 2048;
|
|
31
|
+
/** The UTF-8 bytes of a JSON text. */
|
|
32
|
+
function bytesOf(json) {
|
|
33
|
+
return new TextEncoder().encode(json).length;
|
|
34
|
+
}
|
|
35
|
+
/** Throw a kit failure. */
|
|
36
|
+
function fail(message) {
|
|
37
|
+
throw new Error(`checkTurnRules: ${message}`);
|
|
38
|
+
}
|
|
39
|
+
/** A JSON copy (what the hub stores and reads back). */
|
|
40
|
+
function roundTrip(value) {
|
|
41
|
+
const copy = parseJsonText(JSON.stringify(value));
|
|
42
|
+
if (copy === null && value !== null) fail("a state or move is not JSON");
|
|
43
|
+
return isGamesOwn(copy, value) ? copy : fail("a state or move is not JSON");
|
|
44
|
+
}
|
|
45
|
+
/** Always true: JSON a game's rules made is read back as the game's own type (the game vouches for it, as `toGameData` does). */
|
|
46
|
+
function isGamesOwn(_value, _like) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/** A move for `seat`: the check's own chooser, else the rules' autoMove. */
|
|
50
|
+
function pickMove(rules, options, state, seat, rng) {
|
|
51
|
+
if (options.randomMove !== void 0) return options.randomMove(state, seat, rng);
|
|
52
|
+
if (rules.autoMove === void 0) return fail("the rules have no autoMove: pass options.randomMove");
|
|
53
|
+
return rules.autoMove(state, seat, rng);
|
|
54
|
+
}
|
|
55
|
+
/** An out-of-turn move for `seat`: the check's own chooser, else the rules' autoReact, else none. */
|
|
56
|
+
function pickReaction(rules, options, state, seat, rng) {
|
|
57
|
+
if (options.reactMove !== void 0) return options.reactMove(state, seat, rng);
|
|
58
|
+
return rules.autoReact?.(state, seat, rng) ?? null;
|
|
59
|
+
}
|
|
60
|
+
/** Fail unless every seat in `list` exists. */
|
|
61
|
+
function checkSeats(list, seats, what, seed) {
|
|
62
|
+
if (list.some((seat) => !Number.isInteger(seat) || seat < 0 || seat >= seats)) fail(`${what} named a seat that doesn't exist: [${list.join(", ")}] (seed ${String(seed)})`);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The next move of a game: half the time a seat in `mayAct` that has an
|
|
66
|
+
* out-of-turn move, else a seat in `toAct` (any of them, not only the
|
|
67
|
+
* first). The kit's own `pick` RNG chooses, so the game's RNG sees only
|
|
68
|
+
* the rules' own draws.
|
|
69
|
+
*/
|
|
70
|
+
function nextMove(rules, options, state, toAct, rng, pick) {
|
|
71
|
+
const optional = (rules.mayAct?.(state) ?? []).filter((seat) => !toAct.includes(seat));
|
|
72
|
+
if (optional.length > 0 && pick.int(2) === 0) {
|
|
73
|
+
const seat = pick.pick(optional);
|
|
74
|
+
const move = pickReaction(rules, options, state, seat, rng);
|
|
75
|
+
if (move !== null) return {
|
|
76
|
+
seat,
|
|
77
|
+
move,
|
|
78
|
+
by: options.reactMove === void 0 ? "autoReact" : "reactMove"
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const seat = toAct.length === 0 ? void 0 : pick.pick(toAct);
|
|
82
|
+
return seat === void 0 ? null : {
|
|
83
|
+
seat,
|
|
84
|
+
move: pickMove(rules, options, state, seat, rng),
|
|
85
|
+
by: options.randomMove === void 0 ? "autoMove" : "randomMove"
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Play one seeded game; answers its moves and a trace for the determinism check. */
|
|
89
|
+
function playGame(rules, seats, seed, options) {
|
|
90
|
+
const rng = createTurnRng$1(turnRngStateFromSeed$1(seed));
|
|
91
|
+
const pick = createTurnRng$1(turnRngStateFromSeed$1(seed ^ 1540483477));
|
|
92
|
+
const twinRng = createTurnRng$1(turnRngStateFromSeed$1(seed ^ 668265263));
|
|
93
|
+
const marker = `MARKER-${String(seed)}-`;
|
|
94
|
+
const markerOf = (owner) => `${marker}${String(owner)}`;
|
|
95
|
+
let state = rules.setup({
|
|
96
|
+
seats,
|
|
97
|
+
options: options.setupOptions ?? null,
|
|
98
|
+
mode: options.mode ?? null,
|
|
99
|
+
rng
|
|
100
|
+
});
|
|
101
|
+
if (options.plant !== void 0) for (let seat = 0; seat < seats; seat++) state = options.plant(state, seat, markerOf(seat));
|
|
102
|
+
const maxMoves = options.maxMoves ?? 2e3;
|
|
103
|
+
const trace = [];
|
|
104
|
+
let outOfTurn = 0;
|
|
105
|
+
/** The twin table: each seat's view is the same after what it can't see is shuffled. */
|
|
106
|
+
const checkTwin = () => {
|
|
107
|
+
if (options.shuffleHidden === void 0) return;
|
|
108
|
+
for (let viewer = 0; viewer < seats; viewer++) {
|
|
109
|
+
const real = JSON.stringify(rules.view(state, viewer));
|
|
110
|
+
if (JSON.stringify(rules.view(roundTrip(options.shuffleHidden(roundTrip(state), viewer, twinRng)), viewer)) !== real) fail(`seat ${String(viewer)}'s view changed when what it can't see was shuffled: it shows something hidden (seed ${String(seed)})`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/** The seats whose planted marker is still secret (the rules haven't revealed it). */
|
|
114
|
+
const secret = new Set(options.plant === void 0 ? [] : Array.from({ length: seats }, (_, seat) => seat));
|
|
115
|
+
const checkViews = (events) => {
|
|
116
|
+
for (const owner of secret) if (options.revealed?.(state, markerOf(owner)) === true) secret.delete(owner);
|
|
117
|
+
for (let viewer = 0; viewer < seats; viewer++) {
|
|
118
|
+
const seen = JSON.stringify(rules.view(state, viewer)) + JSON.stringify(events.map((event) => rules.eventView?.(event, viewer) ?? null));
|
|
119
|
+
for (const owner of secret) if (owner !== viewer && seen.includes(markerOf(owner))) fail(`seat ${String(viewer)} can see seat ${String(owner)}'s secret (seed ${String(seed)})`);
|
|
120
|
+
}
|
|
121
|
+
const shown = JSON.stringify(rules.view(state, null));
|
|
122
|
+
for (const owner of secret) if (shown.includes(markerOf(owner))) fail(`the public view shows seat ${String(owner)}'s secret (seed ${String(seed)})`);
|
|
123
|
+
checkTwin();
|
|
124
|
+
};
|
|
125
|
+
checkViews([]);
|
|
126
|
+
for (let moves = 0; moves < maxMoves; moves++) {
|
|
127
|
+
if (rules.outcome(state) !== null) return {
|
|
128
|
+
moves,
|
|
129
|
+
outOfTurn,
|
|
130
|
+
trace: trace.join("|")
|
|
131
|
+
};
|
|
132
|
+
const toAct = rules.toAct(state);
|
|
133
|
+
checkSeats(toAct, seats, "toAct", seed);
|
|
134
|
+
checkSeats(rules.mayAct?.(state) ?? [], seats, "mayAct", seed);
|
|
135
|
+
if (toAct.length === 0) fail(`toAct is empty but there is no outcome yet (seed ${String(seed)}, move ${String(moves)})`);
|
|
136
|
+
const next = nextMove(rules, options, state, toAct, rng, pick);
|
|
137
|
+
if (next === null) return fail(`no seat could move (seed ${String(seed)}, move ${String(moves)})`);
|
|
138
|
+
const { seat, move } = next;
|
|
139
|
+
outOfTurn += toAct.includes(seat) ? 0 : 1;
|
|
140
|
+
if (bytesOf(JSON.stringify(move)) > MOVE_MAX_BYTES) fail(`a move is over ${String(MOVE_MAX_BYTES)} bytes`);
|
|
141
|
+
const result = rules.apply(roundTrip(state), seat, roundTrip(move), rng);
|
|
142
|
+
if ("error" in result) fail(`${next.by} made an illegal move for seat ${String(seat)}: ${result.error} (seed ${String(seed)}, move ${String(moves)})`);
|
|
143
|
+
state = roundTrip(result.state);
|
|
144
|
+
if (bytesOf(JSON.stringify(state)) > STATE_MAX_BYTES) fail(`the state is over ${String(STATE_MAX_BYTES)} bytes`);
|
|
145
|
+
checkViews(result.events ?? []);
|
|
146
|
+
rules.progress(state, seat);
|
|
147
|
+
trace.push(`${String(seat)}:${JSON.stringify(move)}`);
|
|
148
|
+
}
|
|
149
|
+
return fail(`a game did not end within ${String(maxMoves)} moves (seed ${String(seed)})`);
|
|
150
|
+
}
|
|
151
|
+
/** Check a rules module (see the file comment); throws on the first failure. */
|
|
152
|
+
function checkTurnRules(rules, options = {}) {
|
|
153
|
+
if (!Number.isInteger(rules.version) || rules.version < 0) fail("rules.version must be a whole number");
|
|
154
|
+
let games = 0;
|
|
155
|
+
let moves = 0;
|
|
156
|
+
let longestGame = 0;
|
|
157
|
+
let outOfTurn = 0;
|
|
158
|
+
for (const seats of options.seats ?? [2]) for (let game = 0; game < (options.games ?? 25); game++) {
|
|
159
|
+
const seed = seats * 100003 + game;
|
|
160
|
+
const played = playGame(rules, seats, seed, options);
|
|
161
|
+
if (playGame(rules, seats, seed, options).trace !== played.trace) fail(`the same seed played a different game (seed ${String(seed)}): use the rng you are given, never Math.random`);
|
|
162
|
+
games += 1;
|
|
163
|
+
moves += played.moves;
|
|
164
|
+
longestGame = Math.max(longestGame, played.moves);
|
|
165
|
+
outOfTurn += played.outOfTurn;
|
|
166
|
+
}
|
|
167
|
+
for (const fixture of options.fixtures ?? []) if (fixture.version < rules.version) {
|
|
168
|
+
if (rules.migrate === void 0) fail(`a version ${String(fixture.version)} fixture needs rules.migrate`);
|
|
169
|
+
const migrated = rules.migrate(roundTrip(fixture.state), fixture.version);
|
|
170
|
+
rules.view(migrated, null);
|
|
171
|
+
rules.toAct(migrated);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
games,
|
|
175
|
+
moves,
|
|
176
|
+
longestGame,
|
|
177
|
+
outOfTurn
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
export { checkTurnRules, createTurnRng, defineTurnRules, turnRngStateFromSeed };
|
|
182
|
+
|
|
183
|
+
//# sourceMappingURL=turns.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turns.js","names":["createTurnRng","turnRngStateFromSeed"],"sources":["../src/turns-kit/check-rules.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,MAAM,kBAAkB;AACxB,MAAM,iBAAiB;;AAwDvB,SAAS,QAAQ,MAAsB;CACrC,OAAO,IAAI,YAAY,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC;AACxC;;AAGA,SAAS,KAAK,SAAwB;CACpC,MAAM,IAAI,MAAM,mBAAmB,SAAS;AAC9C;;AAGA,SAAS,UAAa,OAAa;CACjC,MAAM,OAAO,cAAc,KAAK,UAAU,KAAK,CAAC;CAChD,IAAI,SAAS,QAAQ,UAAU,MAC7B,KAAK,6BAA6B;CAEpC,OAAO,WAAW,MAAM,KAAK,IAAI,OAAO,KAAK,6BAA6B;AAC5E;;AAIA,SAAS,WAAc,QAAiB,OAAuB;CAC7D,OAAO;AACT;;AAGA,SAAS,SAA4C,OAAqD,SAAsD,OAAc,MAAc,KAAoB;CAC9M,IAAI,QAAQ,eAAe,KAAA,GACzB,OAAO,QAAQ,WAAW,OAAO,MAAM,GAAG;CAE5C,IAAI,MAAM,aAAa,KAAA,GACrB,OAAO,KAAK,qDAAqD;CAEnE,OAAO,MAAM,SAAS,OAAO,MAAM,GAAG;AACxC;;AAGA,SAAS,aAAgD,OAAqD,SAAsD,OAAc,MAAc,KAA2B;CACzN,IAAI,QAAQ,cAAc,KAAA,GACxB,OAAO,QAAQ,UAAU,OAAO,MAAM,GAAG;CAE3C,OAAO,MAAM,YAAY,OAAO,MAAM,GAAG,KAAK;AAChD;;AAGA,SAAS,WAAW,MAAyB,OAAe,MAAc,MAAoB;CAC5F,IAAI,KAAK,MAAM,SAAS,CAAC,OAAO,UAAU,IAAI,KAAK,OAAO,KAAK,QAAQ,KAAK,GAC1E,KAAK,GAAG,KAAK,qCAAqC,KAAK,KAAK,IAAI,EAAE,UAAU,OAAO,IAAI,EAAE,EAAE;AAE/F;;;;;;;AAQA,SAAS,SACP,OAAqD,SAAsD,OAAc,OAA0B,KAAc,MAChH;CACjD,MAAM,YAAY,MAAM,SAAS,KAAK,KAAK,CAAC,EAAA,CAAG,QAAQ,SAAS,CAAC,MAAM,SAAS,IAAI,CAAC;CACrF,IAAI,SAAS,SAAS,KAAK,KAAK,IAAI,CAAC,MAAM,GAAG;EAC5C,MAAM,OAAO,KAAK,KAAK,QAAQ;EAC/B,MAAM,OAAO,aAAa,OAAO,SAAS,OAAO,MAAM,GAAG;EAC1D,IAAI,SAAS,MACX,OAAO;GAAE;GAAM;GAAM,IAAI,QAAQ,cAAc,KAAA,IAAY,cAAc;EAAY;CAEzF;CACA,MAAM,OAAO,MAAM,WAAW,IAAI,KAAA,IAAY,KAAK,KAAK,KAAK;CAC7D,OAAO,SAAS,KAAA,IAAY,OAAO;EAAE;EAAM,MAAM,SAAS,OAAO,SAAS,OAAO,MAAM,GAAG;EAAG,IAAI,QAAQ,eAAe,KAAA,IAAY,aAAa;CAAa;AAChK;;AAGA,SAAS,SACP,OAAqD,OAAe,MAAc,SAC7B;CACrD,MAAM,MAAMA,gBAAcC,uBAAqB,IAAI,CAAC;CACpD,MAAM,OAAOD,gBAAcC,uBAAqB,OAAO,UAAU,CAAC;CAClE,MAAM,UAAUD,gBAAcC,uBAAqB,OAAO,SAAU,CAAC;CACrE,MAAM,SAAS,UAAU,OAAO,IAAI,EAAE;CACtC,MAAM,YAAY,UAA0B,GAAG,SAAS,OAAO,KAAK;CACpE,IAAI,QAAQ,MAAM,MAAM;EAAE;EAAO,SAAS,QAAQ,gBAAgB;EAAM,MAAM,QAAQ,QAAQ;EAAM;CAAI,CAAC;CACzG,IAAI,QAAQ,UAAU,KAAA,GACpB,KAAK,IAAI,OAAO,GAAG,OAAO,OAAO,QAC/B,QAAQ,QAAQ,MAAM,OAAO,MAAM,SAAS,IAAI,CAAC;CAGrD,MAAM,WAAW,QAAQ,YAAY;CACrC,MAAM,QAAkB,CAAC;CACzB,IAAI,YAAY;;CAEhB,MAAM,kBAAwB;EAC5B,IAAI,QAAQ,kBAAkB,KAAA,GAC5B;EAEF,KAAK,IAAI,SAAS,GAAG,SAAS,OAAO,UAAU;GAC7C,MAAM,OAAO,KAAK,UAAU,MAAM,KAAK,OAAO,MAAM,CAAC;GAErD,IADa,KAAK,UAAU,MAAM,KAAK,UAAU,QAAQ,cAAc,UAAU,KAAK,GAAG,QAAQ,OAAO,CAAC,GAAG,MAAM,CAC3G,MAAM,MACX,KAAK,QAAQ,OAAO,MAAM,EAAE,uFAAuF,OAAO,IAAI,EAAE,EAAE;EAEtI;CACF;;CAEA,MAAM,SAAS,IAAI,IAAI,QAAQ,UAAU,KAAA,IAAY,CAAC,IAAI,MAAM,KAAK,EAAE,QAAQ,MAAM,IAAI,GAAG,SAAS,IAAI,CAAC;CAC1G,MAAM,cAAc,WAAmC;EACrD,KAAK,MAAM,SAAS,QAClB,IAAI,QAAQ,WAAW,OAAO,SAAS,KAAK,CAAC,MAAM,MACjD,OAAO,OAAO,KAAK;EAGvB,KAAK,IAAI,SAAS,GAAG,SAAS,OAAO,UAAU;GAC7C,MAAM,OAAO,KAAK,UAAU,MAAM,KAAK,OAAO,MAAM,CAAC,IAAI,KAAK,UAAU,OAAO,KAAK,UAAU,MAAM,YAAY,OAAO,MAAM,KAAK,IAAI,CAAC;GACvI,KAAK,MAAM,SAAS,QAClB,IAAI,UAAU,UAAU,KAAK,SAAS,SAAS,KAAK,CAAC,GACnD,KAAK,QAAQ,OAAO,MAAM,EAAE,gBAAgB,OAAO,KAAK,EAAE,kBAAkB,OAAO,IAAI,EAAE,EAAE;EAGjG;EACA,MAAM,QAAQ,KAAK,UAAU,MAAM,KAAK,OAAO,IAAI,CAAC;EACpD,KAAK,MAAM,SAAS,QAClB,IAAI,MAAM,SAAS,SAAS,KAAK,CAAC,GAChC,KAAK,8BAA8B,OAAO,KAAK,EAAE,kBAAkB,OAAO,IAAI,EAAE,EAAE;EAGtF,UAAU;CACZ;CACA,WAAW,CAAC,CAAC;CACb,KAAK,IAAI,QAAQ,GAAG,QAAQ,UAAU,SAAS;EAC7C,IAAI,MAAM,QAAQ,KAAK,MAAM,MAC3B,OAAO;GAAE;GAAO;GAAW,OAAO,MAAM,KAAK,GAAG;EAAE;EAEpD,MAAM,QAAQ,MAAM,MAAM,KAAK;EAC/B,WAAW,OAAO,OAAO,SAAS,IAAI;EACtC,WAAW,MAAM,SAAS,KAAK,KAAK,CAAC,GAAG,OAAO,UAAU,IAAI;EAC7D,IAAI,MAAM,WAAW,GACnB,KAAK,oDAAoD,OAAO,IAAI,EAAE,SAAS,OAAO,KAAK,EAAE,EAAE;EAEjG,MAAM,OAAO,SAAS,OAAO,SAAS,OAAO,OAAO,KAAK,IAAI;EAC7D,IAAI,SAAS,MACX,OAAO,KAAK,4BAA4B,OAAO,IAAI,EAAE,SAAS,OAAO,KAAK,EAAE,EAAE;EAEhF,MAAM,EAAE,MAAM,SAAS;EACvB,aAAa,MAAM,SAAS,IAAI,IAAI,IAAI;EACxC,IAAI,QAAQ,KAAK,UAAU,IAAI,CAAC,IAAI,gBAClC,KAAK,kBAAkB,OAAO,cAAc,EAAE,OAAO;EAEvD,MAAM,SAAS,MAAM,MAAM,UAAU,KAAK,GAAG,MAAM,UAAU,IAAI,GAAG,GAAG;EACvE,IAAI,WAAW,QACb,KAAK,GAAG,KAAK,GAAG,iCAAiC,OAAO,IAAI,EAAE,IAAI,OAAO,MAAM,SAAS,OAAO,IAAI,EAAE,SAAS,OAAO,KAAK,EAAE,EAAE;EAEhI,QAAQ,UAAU,OAAO,KAAK;EAC9B,IAAI,QAAQ,KAAK,UAAU,KAAK,CAAC,IAAI,iBACnC,KAAK,qBAAqB,OAAO,eAAe,EAAE,OAAO;EAE3D,WAAW,OAAO,UAAU,CAAC,CAAC;EAC9B,MAAM,SAAS,OAAO,IAAI;EAC1B,MAAM,KAAK,GAAG,OAAO,IAAI,EAAE,GAAG,KAAK,UAAU,IAAI,GAAG;CACtD;CACA,OAAO,KAAK,6BAA6B,OAAO,QAAQ,EAAE,eAAe,OAAO,IAAI,EAAE,EAAE;AAC1F;;AAGA,SAAgB,eACd,OAAqD,UAAuD,CAAC,GAC5F;CACjB,IAAI,CAAC,OAAO,UAAU,MAAM,OAAO,KAAK,MAAM,UAAU,GACtD,KAAK,sCAAsC;CAE7C,IAAI,QAAQ;CACZ,IAAI,QAAQ;CACZ,IAAI,cAAc;CAClB,IAAI,YAAY;CAChB,KAAK,MAAM,SAAS,QAAQ,SAAS,CAAC,CAAC,GACrC,KAAK,IAAI,OAAO,GAAG,QAAQ,QAAQ,SAAS,KAAK,QAAQ;EACvD,MAAM,OAAO,QAAQ,SAAU;EAC/B,MAAM,SAAS,SAAS,OAAO,OAAO,MAAM,OAAO;EACnD,IAAI,SAAS,OAAO,OAAO,MAAM,OAAO,CAAC,CAAC,UAAU,OAAO,OACzD,KAAK,+CAA+C,OAAO,IAAI,EAAE,gDAAgD;EAEnH,SAAS;EACT,SAAS,OAAO;EAChB,cAAc,KAAK,IAAI,aAAa,OAAO,KAAK;EAChD,aAAa,OAAO;CACtB;CAEF,KAAK,MAAM,WAAW,QAAQ,YAAY,CAAC,GACzC,IAAI,QAAQ,UAAU,MAAM,SAAS;EACnC,IAAI,MAAM,YAAY,KAAA,GACpB,KAAK,aAAa,OAAO,QAAQ,OAAO,EAAE,6BAA6B;EAEzE,MAAM,WAAW,MAAM,QAAQ,UAAU,QAAQ,KAAK,GAAG,QAAQ,OAAO;EACxE,MAAM,KAAK,UAAU,IAAI;EACzB,MAAM,MAAM,QAAQ;CACtB;CAEF,OAAO;EAAE;EAAO;EAAO;EAAa;CAAU;AAChD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diffenderfer-games/hub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.10",
|
|
4
4
|
"description": "The diffenderfer.games hub client for games: accounts, saves, stats, leaderboards, inventory, daily challenges, the input system and the social/multiplayer forwarders, typed from the hub contract.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
13
|
"url": "git+https://github.com/diffenderfer-games/diffenderfer-games.git",
|
|
14
|
-
"directory": "
|
|
14
|
+
"directory": "packages/public/hub"
|
|
15
15
|
},
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=20"
|
|
@@ -59,11 +59,41 @@
|
|
|
59
59
|
"types": "./dist/rooms.d.ts",
|
|
60
60
|
"default": "./dist/rooms.js"
|
|
61
61
|
},
|
|
62
|
+
"./turns": {
|
|
63
|
+
"source": "./src/turns.ts",
|
|
64
|
+
"types": "./dist/turns.d.ts",
|
|
65
|
+
"default": "./dist/turns.js"
|
|
66
|
+
},
|
|
67
|
+
"./turns-rules": {
|
|
68
|
+
"source": "./src/turns-rules.ts",
|
|
69
|
+
"types": "./dist/turns-rules.d.ts",
|
|
70
|
+
"default": "./dist/turns-rules.js"
|
|
71
|
+
},
|
|
62
72
|
"./types": {
|
|
63
73
|
"source": "./src/types.ts",
|
|
64
74
|
"types": "./dist/types.d.ts",
|
|
65
75
|
"default": "./dist/types.js"
|
|
66
76
|
},
|
|
77
|
+
"./avatar": {
|
|
78
|
+
"source": "./src/avatar.ts",
|
|
79
|
+
"types": "./dist/avatar.d.ts",
|
|
80
|
+
"default": "./dist/avatar.js"
|
|
81
|
+
},
|
|
82
|
+
"./avatar/pixi": {
|
|
83
|
+
"source": "./src/avatar-pixi.ts",
|
|
84
|
+
"types": "./dist/avatar-pixi.d.ts",
|
|
85
|
+
"default": "./dist/avatar-pixi.js"
|
|
86
|
+
},
|
|
87
|
+
"./avatar/three": {
|
|
88
|
+
"source": "./src/avatar-three.ts",
|
|
89
|
+
"types": "./dist/avatar-three.d.ts",
|
|
90
|
+
"default": "./dist/avatar-three.js"
|
|
91
|
+
},
|
|
92
|
+
"./avatar/svg": {
|
|
93
|
+
"source": "./src/avatar-svg.ts",
|
|
94
|
+
"types": "./dist/avatar-svg.d.ts",
|
|
95
|
+
"default": "./dist/avatar-svg.js"
|
|
96
|
+
},
|
|
67
97
|
"./auto": {
|
|
68
98
|
"source": "./src/auto.ts",
|
|
69
99
|
"types": "./dist/auto.d.ts",
|
|
@@ -81,9 +111,15 @@
|
|
|
81
111
|
},
|
|
82
112
|
"dependencies": {
|
|
83
113
|
"@ayepi/core": "0.4.1",
|
|
84
|
-
"@diffenderfer-games/hub-contract": "2.0.0-next.
|
|
114
|
+
"@diffenderfer-games/hub-contract": "2.0.0-next.10",
|
|
115
|
+
"@diffenderfer-games/world": "2.0.0-next.10"
|
|
85
116
|
},
|
|
86
117
|
"publishConfig": {
|
|
87
118
|
"access": "public"
|
|
119
|
+
},
|
|
120
|
+
"devDependencies": {
|
|
121
|
+
"@types/three": "0.186.0",
|
|
122
|
+
"pixi.js": "8.19.0",
|
|
123
|
+
"three": "0.186.1"
|
|
88
124
|
}
|
|
89
125
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One live avatar in Pixi (§3.8): a sprite backed by a canvas that redraws
|
|
3
|
+
* only when the pose changes, so a dozen animated faces stay cheap. Pass the
|
|
4
|
+
* game's pixi namespace; the hub never imports pixi.
|
|
5
|
+
*/
|
|
6
|
+
import type { Animator } from '../animator/animator';
|
|
7
|
+
import { drawToCanvas } from '../outputs/bitmap';
|
|
8
|
+
import { liveCanvas } from '../outputs/live-canvas';
|
|
9
|
+
import type { LookLike, Pose, RenderOptions } from '../render/types';
|
|
10
|
+
import { refresh, type PixiNs, type PixiRectangle, type PixiSprite, type PixiTexture } from './pixi-types';
|
|
11
|
+
|
|
12
|
+
/** Options for {@link pixiAvatar}. */
|
|
13
|
+
export interface PixiAvatarOptions extends RenderOptions {
|
|
14
|
+
/** Drives the face each `update(t)`; without one the avatar is still. */
|
|
15
|
+
readonly animator?: Animator;
|
|
16
|
+
/** Texture pixels per CSS pixel (default 2). */
|
|
17
|
+
readonly resolution?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A live Pixi avatar; `view` is the game's own pixi `Sprite`. */
|
|
21
|
+
export interface PixiAvatar<TSprite extends PixiSprite = PixiSprite> {
|
|
22
|
+
/** The sprite to add to the stage (anchored at its centre). */
|
|
23
|
+
readonly view: TSprite;
|
|
24
|
+
/** Renders the animator's pose at `t` (or a given pose). */
|
|
25
|
+
update(t: number | Pose): void;
|
|
26
|
+
/** Swaps the look. */
|
|
27
|
+
setLook(like: LookLike): void;
|
|
28
|
+
destroy(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A live avatar sprite: `pixiAvatar(pixi, seat.look, { size: 64, animator })`. */
|
|
32
|
+
export function pixiAvatar<TTexture extends PixiTexture, TSprite extends PixiSprite<TTexture>, TRectangle extends PixiRectangle>(
|
|
33
|
+
pixi: PixiNs<TTexture, TSprite, TRectangle>,
|
|
34
|
+
like: LookLike,
|
|
35
|
+
opts: PixiAvatarOptions = {},
|
|
36
|
+
): PixiAvatar<TSprite> {
|
|
37
|
+
const resolution = opts.resolution ?? 2;
|
|
38
|
+
const live = liveCanvas(like, opts, resolution);
|
|
39
|
+
live.draw(opts.pose);
|
|
40
|
+
const texture = pixi.Texture.from(live.canvas);
|
|
41
|
+
const view = new pixi.Sprite(texture);
|
|
42
|
+
view.anchor.set(0.5);
|
|
43
|
+
view.scale.set(1 / resolution);
|
|
44
|
+
return {
|
|
45
|
+
view,
|
|
46
|
+
update(t) {
|
|
47
|
+
const pose = typeof t === 'number' ? opts.animator?.pose(t) : t;
|
|
48
|
+
if (live.draw(pose ?? opts.pose)) {
|
|
49
|
+
refresh(texture);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
setLook(next) {
|
|
53
|
+
live.setLook(next);
|
|
54
|
+
opts.animator?.setLook(next);
|
|
55
|
+
if (live.draw(opts.pose)) {
|
|
56
|
+
refresh(texture);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
destroy() {
|
|
60
|
+
view.destroy();
|
|
61
|
+
texture.destroy(true);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A still avatar texture (Pixi 7 games use this from a bitmap-like canvas). */
|
|
67
|
+
export function avatarTexture<TTexture extends PixiTexture, TSprite extends PixiSprite<TTexture>, TRectangle extends PixiRectangle>(
|
|
68
|
+
pixi: PixiNs<TTexture, TSprite, TRectangle>,
|
|
69
|
+
like: LookLike,
|
|
70
|
+
opts: RenderOptions = {},
|
|
71
|
+
): TTexture {
|
|
72
|
+
return pixi.Texture.from(drawToCanvas(like, opts));
|
|
73
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createCrowd` (§3.11): the world's whole avatar layer in Pixi 8. One
|
|
3
|
+
* y-sorted container; most avatars are atlas frames (shared page textures,
|
|
4
|
+
* so Pixi batches them), and up to `liveMax` flagged ones (nearest, hovered,
|
|
5
|
+
* talking, friends; the world decides) are live vector avatars with full
|
|
6
|
+
* expressions. Name tags and bubbles are cached textures, re-rendered only
|
|
7
|
+
* when their text changes. Baking is time-sliced; an avatar without a baked
|
|
8
|
+
* frame yet shows the silhouette.
|
|
9
|
+
*/
|
|
10
|
+
import { animator, type Animator } from '../animator/animator';
|
|
11
|
+
import type { AvatarAtlas } from '../atlas';
|
|
12
|
+
import { expressionFromEmote } from '../face';
|
|
13
|
+
import { layout } from '../layout';
|
|
14
|
+
import { toCode } from '../look-like';
|
|
15
|
+
import { defaultLayer } from '../outputs/canvas';
|
|
16
|
+
import { paintNodes } from '../outputs/canvas-paint';
|
|
17
|
+
import { overlays, type Overlay, type ReactionGlyph } from '../overlays';
|
|
18
|
+
import { frameBox } from '../render/frame';
|
|
19
|
+
import type { BodyPose, Facing, Figure, LookLike, StatusProp } from '../render/types';
|
|
20
|
+
import { pixiAvatar, type PixiAvatar } from './pixi-avatar';
|
|
21
|
+
import { refresh, type PixiContainer, type PixiNs, type PixiRectangle, type PixiSprite, type PixiTexture } from './pixi-types';
|
|
22
|
+
|
|
23
|
+
/** One avatar's state in the crowd (the world's own protocol fills it). */
|
|
24
|
+
export interface CrowdMember {
|
|
25
|
+
readonly look: LookLike;
|
|
26
|
+
readonly x: number;
|
|
27
|
+
readonly y: number;
|
|
28
|
+
readonly facing?: Facing;
|
|
29
|
+
readonly body?: BodyPose;
|
|
30
|
+
readonly status?: StatusProp | null;
|
|
31
|
+
/** The username for the name tag (omit for no tag). */
|
|
32
|
+
readonly name?: string;
|
|
33
|
+
readonly friend?: boolean;
|
|
34
|
+
readonly you?: boolean;
|
|
35
|
+
/** "Playing Uno": the game's catalog title. */
|
|
36
|
+
readonly activity?: string;
|
|
37
|
+
/** A quick-chat emote to pop (shown for 2.5 s from when it's set). */
|
|
38
|
+
readonly emote?: string;
|
|
39
|
+
readonly reaction?: ReactionGlyph;
|
|
40
|
+
/** Ask for live vector rendering (granted to at most `liveMax`). */
|
|
41
|
+
readonly live?: boolean;
|
|
42
|
+
/** Hide the tag (the world fades tags by distance and hides them when crowded). */
|
|
43
|
+
readonly tagHidden?: boolean;
|
|
44
|
+
/** Moves the tag and bubble up by this much (container units), so a world can stack tags that would overlap. */
|
|
45
|
+
readonly tagLift?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Crowd options; `container` is the game's own pixi `Container`. */
|
|
49
|
+
export interface CrowdOptions<TSprite extends PixiSprite = PixiSprite> {
|
|
50
|
+
readonly atlas: AvatarAtlas;
|
|
51
|
+
/** The container avatars go into (it is made y-sortable). */
|
|
52
|
+
readonly container: PixiContainer<TSprite>;
|
|
53
|
+
/** Live vector avatars at most (default 6; 2 on low-end devices). */
|
|
54
|
+
readonly liveMax?: number;
|
|
55
|
+
/** Atlas baking budget per update, ms (default 4). */
|
|
56
|
+
readonly bakeMs?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Where name tags and bubbles go (default `container`). A world puts them in
|
|
59
|
+
* a container above its lighting so tags stay readable at night.
|
|
60
|
+
*/
|
|
61
|
+
readonly overlays?: PixiContainer<TSprite>;
|
|
62
|
+
/**
|
|
63
|
+
* Draws atlas avatars at this scale (default 1). Bake the atlas at
|
|
64
|
+
* `scale × devicePixelRatio` and pass `1 / devicePixelRatio` for crisp
|
|
65
|
+
* avatars on high-density screens; positions stay in the container's units.
|
|
66
|
+
*/
|
|
67
|
+
readonly displayScale?: number;
|
|
68
|
+
/**
|
|
69
|
+
* The figure the atlas draws (default `blob`). Pass the atlas's own
|
|
70
|
+
* figure, so name tags sit on the right heads and walks step in time.
|
|
71
|
+
*/
|
|
72
|
+
readonly figure?: Figure;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The crowd. */
|
|
76
|
+
export interface AvatarCrowd {
|
|
77
|
+
set(id: string | number, member: CrowdMember): void;
|
|
78
|
+
remove(id: string | number): void;
|
|
79
|
+
/** Advances animation to `t` (ms), bakes, and lays everyone out. Call once per frame. */
|
|
80
|
+
update(t: number): void;
|
|
81
|
+
destroy(): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A cached overlay sprite and the key it shows. */
|
|
85
|
+
interface OverlaySlot<TSprite extends PixiSprite> {
|
|
86
|
+
sprite: TSprite | null;
|
|
87
|
+
key: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Entry<TSprite extends PixiSprite> {
|
|
91
|
+
member: CrowdMember;
|
|
92
|
+
readonly sprite: TSprite;
|
|
93
|
+
readonly tag: OverlaySlot<TSprite>;
|
|
94
|
+
readonly bubble: OverlaySlot<TSprite>;
|
|
95
|
+
emoteAt: number;
|
|
96
|
+
/** A baked frame has been shown (while a new look bakes, the old one stays instead of the silhouette). */
|
|
97
|
+
hasFrame: boolean;
|
|
98
|
+
live: PixiAvatar<TSprite> | null;
|
|
99
|
+
body: BodyPose | null;
|
|
100
|
+
readonly anim: Animator;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const EMOTE_MS = 2500;
|
|
104
|
+
const CYCLE: Readonly<Partial<Record<BodyPose, number>>> = { walk: 520, run: 320, dance: 900, jump: 700, cheer: 600, idle: 2400 };
|
|
105
|
+
/** The walker's shorter legs step quicker. */
|
|
106
|
+
const WALKER_CYCLE: Readonly<Partial<Record<BodyPose, number>>> = { ...CYCLE, walk: 440, run: 300, idle: 2800 };
|
|
107
|
+
|
|
108
|
+
/** A crowd over an atlas: `createCrowd(pixi, { atlas, container })`. */
|
|
109
|
+
export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSprite<TTexture>, TRectangle extends PixiRectangle>(
|
|
110
|
+
pixi: PixiNs<TTexture, TSprite, TRectangle>,
|
|
111
|
+
opts: CrowdOptions<NoInfer<TSprite>>,
|
|
112
|
+
): AvatarCrowd {
|
|
113
|
+
const { atlas, container } = opts;
|
|
114
|
+
const overlayLayer = opts.overlays ?? container;
|
|
115
|
+
const displayScale = opts.displayScale ?? 1;
|
|
116
|
+
container.sortableChildren = true;
|
|
117
|
+
overlayLayer.sortableChildren = true;
|
|
118
|
+
const liveMax = opts.liveMax ?? 6;
|
|
119
|
+
const figure = opts.figure ?? 'blob';
|
|
120
|
+
const cycles = figure === 'walker' ? WALKER_CYCLE : CYCLE;
|
|
121
|
+
const [, liveTop, , liveHeight] = frameBox({ frame: 'full', figure });
|
|
122
|
+
const entries = new Map<string, Entry<TSprite>>();
|
|
123
|
+
const pageTextures: TTexture[] = [];
|
|
124
|
+
const frameTextures = new Map<string, TTexture>();
|
|
125
|
+
const overlayTextures = new Map<string, { texture: TTexture; ax: number; ay: number }>();
|
|
126
|
+
|
|
127
|
+
/** Where the name plate sits above the feet (rig units), cached per look, pose and prop. */
|
|
128
|
+
const nameplates = new Map<string, number>();
|
|
129
|
+
const nameplateY = (look: LookLike, body: BodyPose, status: StatusProp | null): number => {
|
|
130
|
+
const key = `${toCode(look)}|${body}|${status ?? ''}`;
|
|
131
|
+
let plateY = nameplates.get(key);
|
|
132
|
+
if (plateY === undefined) {
|
|
133
|
+
plateY = layout(look, { figure, pose: { body, status } }).nameplate.y;
|
|
134
|
+
nameplates.set(key, plateY);
|
|
135
|
+
}
|
|
136
|
+
return plateY;
|
|
137
|
+
};
|
|
138
|
+
const overlayTexture = (key: string, overlay: Overlay): { texture: TTexture; ax: number; ay: number } | null => {
|
|
139
|
+
const hit = overlayTextures.get(key);
|
|
140
|
+
if (hit) {
|
|
141
|
+
return hit;
|
|
142
|
+
}
|
|
143
|
+
const [left, top, w, h] = overlay.box;
|
|
144
|
+
const resolution = 2;
|
|
145
|
+
const made = defaultLayer(Math.ceil(w * resolution), Math.ceil(h * resolution));
|
|
146
|
+
if (!made || !('getContext' in made.canvas)) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
made.ctx.scale(resolution, resolution);
|
|
150
|
+
made.ctx.translate(-left, -top);
|
|
151
|
+
paintNodes(made.ctx, overlay.nodes);
|
|
152
|
+
const texture = pixi.Texture.from(made.canvas);
|
|
153
|
+
const out = { texture, ax: -left / w, ay: -top / h };
|
|
154
|
+
overlayTextures.set(key, out);
|
|
155
|
+
return out;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const textureFor = (page: number, left: number, top: number, w: number, h: number): TTexture | null => {
|
|
159
|
+
const sources = atlas.sources;
|
|
160
|
+
while (pageTextures.length < sources.length) {
|
|
161
|
+
const source = sources[pageTextures.length];
|
|
162
|
+
if (!source || !('getContext' in source)) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
pageTextures.push(pixi.Texture.from(source));
|
|
166
|
+
}
|
|
167
|
+
const base = pageTextures[page]?.source;
|
|
168
|
+
if (!base) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const key = `${String(page)}:${String(left)}:${String(top)}`;
|
|
172
|
+
let tex = frameTextures.get(key);
|
|
173
|
+
if (!tex) {
|
|
174
|
+
tex = new pixi.Texture({ source: base, frame: new pixi.Rectangle(left, top, w, h) });
|
|
175
|
+
frameTextures.set(key, tex);
|
|
176
|
+
}
|
|
177
|
+
return tex;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const placeOverlay = (slot: OverlaySlot<TSprite>, key: string, make: () => Overlay | null, atX: number, atY: number): void => {
|
|
181
|
+
if (key === '') {
|
|
182
|
+
if (slot.sprite) {
|
|
183
|
+
slot.sprite.visible = false;
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
let sprite = slot.sprite;
|
|
188
|
+
if (slot.key !== key || !sprite) {
|
|
189
|
+
const overlay = make();
|
|
190
|
+
const made = overlay ? overlayTexture(key, overlay) : null;
|
|
191
|
+
if (!made) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!sprite) {
|
|
195
|
+
sprite = new pixi.Sprite(made.texture);
|
|
196
|
+
overlayLayer.addChild(sprite);
|
|
197
|
+
}
|
|
198
|
+
slot.sprite = sprite;
|
|
199
|
+
sprite.texture = made.texture;
|
|
200
|
+
sprite.anchor.set(made.ax, made.ay);
|
|
201
|
+
sprite.scale.set(0.5);
|
|
202
|
+
slot.key = key;
|
|
203
|
+
}
|
|
204
|
+
sprite.visible = true;
|
|
205
|
+
sprite.x = atX;
|
|
206
|
+
sprite.y = atY;
|
|
207
|
+
sprite.zIndex = 1e6 + atY;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
set(id, member) {
|
|
212
|
+
const key = String(id);
|
|
213
|
+
const entry = entries.get(key);
|
|
214
|
+
if (entry) {
|
|
215
|
+
if (member.emote && member.emote !== entry.member.emote) {
|
|
216
|
+
entry.emoteAt = -1;
|
|
217
|
+
const face = expressionFromEmote(member.emote);
|
|
218
|
+
if (face) {
|
|
219
|
+
entry.anim.express(face);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (member.look !== entry.member.look) {
|
|
223
|
+
entry.live?.setLook(member.look);
|
|
224
|
+
}
|
|
225
|
+
entry.member = member;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const sprite = new pixi.Sprite();
|
|
229
|
+
container.addChild(sprite);
|
|
230
|
+
entries.set(key, {
|
|
231
|
+
member, sprite, tag: { sprite: null, key: '' }, bubble: { sprite: null, key: '' }, emoteAt: member.emote ? -1 : 0, hasFrame: false, live: null, body: null,
|
|
232
|
+
anim: animator(member.look, { seed: key }),
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
remove(id) {
|
|
236
|
+
const entry = entries.get(String(id));
|
|
237
|
+
if (!entry) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
entry.sprite.destroy();
|
|
241
|
+
entry.tag.sprite?.destroy();
|
|
242
|
+
entry.bubble.sprite?.destroy();
|
|
243
|
+
entry.live?.destroy();
|
|
244
|
+
entries.delete(String(id));
|
|
245
|
+
},
|
|
246
|
+
update(t) {
|
|
247
|
+
if (atlas.bake(opts.bakeMs ?? 4) > 0) {
|
|
248
|
+
pageTextures.forEach(refresh);
|
|
249
|
+
}
|
|
250
|
+
const wantsLive = [...entries.values()].filter((e) => e.member.live === true).slice(0, liveMax);
|
|
251
|
+
const liveSet = new Set(wantsLive);
|
|
252
|
+
for (const entry of entries.values()) {
|
|
253
|
+
const m = entry.member;
|
|
254
|
+
const body = m.body ?? 'idle';
|
|
255
|
+
if (entry.emoteAt === -1) {
|
|
256
|
+
entry.emoteAt = t;
|
|
257
|
+
}
|
|
258
|
+
const isEmoting = m.emote !== undefined && t - entry.emoteAt < EMOTE_MS;
|
|
259
|
+
if (liveSet.has(entry)) {
|
|
260
|
+
if (!entry.live) {
|
|
261
|
+
entry.live = pixiAvatar(pixi, m.look, { frame: 'full', figure, size: 64, rim: true, shadow: false, animator: entry.anim });
|
|
262
|
+
container.addChild(entry.live.view);
|
|
263
|
+
}
|
|
264
|
+
entry.sprite.visible = false;
|
|
265
|
+
const view = entry.live.view;
|
|
266
|
+
if (entry.body !== body) {
|
|
267
|
+
entry.body = body;
|
|
268
|
+
entry.anim.setBody(body, { at: t });
|
|
269
|
+
}
|
|
270
|
+
entry.live.update({ ...entry.anim.pose(t), body, status: m.status ?? null, phase: (t / (cycles[body] ?? 600)) % 1 });
|
|
271
|
+
view.x = m.x;
|
|
272
|
+
view.y = m.y + (figure === 'walker' ? (liveTop + liveHeight / 2) * (64 / liveHeight) : -32);
|
|
273
|
+
view.zIndex = m.y;
|
|
274
|
+
view.visible = true;
|
|
275
|
+
} else {
|
|
276
|
+
if (entry.live) {
|
|
277
|
+
entry.live.view.visible = false;
|
|
278
|
+
}
|
|
279
|
+
const frame = atlas.frame({
|
|
280
|
+
look: m.look, facing: m.facing ?? 'S', body, status: m.status ?? null,
|
|
281
|
+
phase: (t / (cycles[body] ?? 600)) % 1, face: isEmoting ? 'happy' : 'neutral',
|
|
282
|
+
});
|
|
283
|
+
// Until its first frame bakes, a member shows the silhouette, anchored and scaled like a frame.
|
|
284
|
+
const shown = frame ?? (entry.hasFrame ? null : atlas.frame({ look: null }));
|
|
285
|
+
entry.hasFrame ||= frame !== null;
|
|
286
|
+
const tex = shown ? textureFor(shown.page, shown.x, shown.y, shown.w, shown.h) : null;
|
|
287
|
+
if (tex) {
|
|
288
|
+
entry.sprite.texture = tex;
|
|
289
|
+
entry.sprite.visible = true;
|
|
290
|
+
}
|
|
291
|
+
if (shown) {
|
|
292
|
+
entry.sprite.anchor.set(shown.footX / shown.w, shown.footY / shown.h);
|
|
293
|
+
entry.sprite.scale.set(shown.mirror ? -displayScale : displayScale, displayScale);
|
|
294
|
+
}
|
|
295
|
+
entry.sprite.x = m.x;
|
|
296
|
+
entry.sprite.y = m.y;
|
|
297
|
+
entry.sprite.zIndex = m.y;
|
|
298
|
+
}
|
|
299
|
+
const top = m.y - (m.tagLift ?? 0) + nameplateY(m.look, body, m.status ?? null) * (entry.live?.view.visible === true ? 64 / liveHeight : atlas.scale * displayScale);
|
|
300
|
+
const tagKey = m.name && !m.tagHidden ? `tag:${m.name}:${m.friend ? 1 : 0}:${m.you ? 1 : 0}` : '';
|
|
301
|
+
placeOverlay(entry.tag, tagKey, () => (m.name ? overlays.nameTag({ name: m.name, friend: m.friend === true, you: m.you === true }) : null), m.x, top);
|
|
302
|
+
const bubbleKey = isEmoting && m.emote ? `emote:${m.emote}` : m.reaction ? `react:${m.reaction}` : m.activity ? `act:${m.activity}` : '';
|
|
303
|
+
placeOverlay(entry.bubble, bubbleKey, () => {
|
|
304
|
+
if (isEmoting && m.emote) {
|
|
305
|
+
return overlays.emoteBubble(m.emote);
|
|
306
|
+
}
|
|
307
|
+
if (m.reaction) {
|
|
308
|
+
return overlays.reactionBubble(m.reaction);
|
|
309
|
+
}
|
|
310
|
+
return m.activity ? overlays.activityBubble({ game: m.activity }) : null;
|
|
311
|
+
}, m.x, top - 12);
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
destroy() {
|
|
315
|
+
for (const id of [...entries.keys()]) {
|
|
316
|
+
this.remove(id);
|
|
317
|
+
}
|
|
318
|
+
for (const tex of frameTextures.values()) {
|
|
319
|
+
tex.destroy(false);
|
|
320
|
+
}
|
|
321
|
+
for (const tex of pageTextures) {
|
|
322
|
+
tex.destroy(true);
|
|
323
|
+
}
|
|
324
|
+
for (const o of overlayTextures.values()) {
|
|
325
|
+
o.texture.destroy(true);
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|