@irtio/lobby 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -40,6 +40,35 @@ interface AttachableRoom {
40
40
  connected: boolean;
41
41
  }[]) => void): () => void;
42
42
  on(event: 'rtt', cb: (rtt: number) => void): () => void;
43
+ /**
44
+ * D75: the room's lobby, when it has one. Absent lobby, absent UI — every consumer that existed
45
+ * before this renders exactly as it did, and the package still imports nothing.
46
+ *
47
+ * Structural, like the rest of this type. `@irtio/client`'s `lobbyOf(room)` answers this shape,
48
+ * and so does a hand-rolled object in a framework app that would rather drive the panel itself.
49
+ */
50
+ readonly lobby?: AttachableLobby;
51
+ }
52
+ /** D75: the lobby half of an attachable room. See `AttachableRoom.lobby`. */
53
+ interface AttachableLobby {
54
+ readonly phase: 'lobby' | 'started';
55
+ readonly players: readonly {
56
+ ready: boolean;
57
+ connected: boolean;
58
+ }[];
59
+ /** Whether the room is currently findable by strangers. The ROOM's state, never the click. */
60
+ readonly public?: boolean;
61
+ /** True only under a `'when-ready'` start policy; the only thing that draws readiness. */
62
+ readonly readyUi?: boolean;
63
+ /** How many players the room starts at, when it starts on a count. */
64
+ readonly capacity?: number;
65
+ ready(value: boolean): void;
66
+ /**
67
+ * Present only when the app wired one. The platform takes no public toggle from a client — that
68
+ * would be a built-in RPC — so a game that wants this offers its own and passes it through.
69
+ */
70
+ setPublic?(value: boolean): void;
71
+ on(event: 'change', cb: () => void): () => void;
43
72
  }
44
73
  /**
45
74
  * The custom element class. Registered as `irt-lobby` by `defineLobby()`, which this package
@@ -70,6 +99,55 @@ declare class IrtLobbyElement extends HTMLElement {
70
99
  /** The room's capacity, if known. `0` or the attribute absent both mean "unknown". */
71
100
  get maxPlayers(): number;
72
101
  set maxPlayers(value: number);
102
+ /**
103
+ * D75: the panel is the game's front door. Mirrors the `quick-match` boolean attribute.
104
+ *
105
+ * With it, and before a room is attached, the panel renders the chooser: **Start a private
106
+ * lobby** and **Join a game**. It also unlocks the searching state and the public toggle. Without
107
+ * it the panel is exactly what it has always been.
108
+ */
109
+ get quickMatch(): boolean;
110
+ set quickMatch(value: boolean);
111
+ /**
112
+ * D75: this game has no private games. Mirrors the `no-private` boolean attribute.
113
+ *
114
+ * Only meaningful alongside `quick-match`: the chooser drops to **Join a game** alone and the
115
+ * code, link and QR never render, because there is nothing to share. On its own it is invalid —
116
+ * the panel renders as it always did and says so once on the console — because a panel with no
117
+ * private games and no quick match is a panel with no way in at all.
118
+ */
119
+ get noPrivate(): boolean;
120
+ set noPrivate(value: boolean);
121
+ /** D75: a quick match is in flight. Mirrors `searching`; `attachLobby` sets and clears it. */
122
+ get searching(): boolean;
123
+ set searching(value: boolean);
124
+ /**
125
+ * D75: how many players are waiting in the queue, from `GET /match/status`.
126
+ *
127
+ * Rendered under "finding a game…" as `n of m waiting`, and only when both are set. Absent, the
128
+ * searching state is an honest spinner rather than a made-up count.
129
+ */
130
+ get waiting(): number | undefined;
131
+ set waiting(value: number | undefined);
132
+ /** D75: how many the queue takes. Mirrors `waiting-of`. */
133
+ get waitingOf(): number | undefined;
134
+ set waitingOf(value: number | undefined);
135
+ /**
136
+ * D75: the queue name the **Join a game** event carries. Mirrors the `queue` attribute.
137
+ *
138
+ * Empty means "the project's default queue", which is what a game with one queue wants and what
139
+ * omitting it has always meant everywhere else in the platform.
140
+ */
141
+ get queue(): string;
142
+ set queue(value: string);
143
+ /**
144
+ * D75: draw the roster but not the ready button, for a game that draws its own.
145
+ *
146
+ * Mirrors `hide-ready`, and only bites under a `'when-ready'` room — the other two policies have
147
+ * no ready button to hide.
148
+ */
149
+ get hideReady(): boolean;
150
+ set hideReady(value: boolean);
73
151
  /** Whether the panel renders inside a fixed full-viewport backdrop. Mirrors `modal`. */
74
152
  get modal(): boolean;
75
153
  set modal(value: boolean);
@@ -239,4 +317,4 @@ interface QrMatrix {
239
317
  */
240
318
  declare function encodeQr(text: string): QrMatrix;
241
319
 
242
- export { type AttachableRoom, IrtLobbyElement, IrtProfileElement, IrtStatusElement, LOBBY_TAG, PROFILE_TAG, type ProfilableRoom, type ProfileReading, type QrMatrix, STATUS_TAG, defineLobby, defineProfile, defineStatus, encodeQr };
320
+ export { type AttachableLobby, type AttachableRoom, IrtLobbyElement, IrtProfileElement, IrtStatusElement, LOBBY_TAG, PROFILE_TAG, type ProfilableRoom, type ProfileReading, type QrMatrix, STATUS_TAG, defineLobby, defineProfile, defineStatus, encodeQr };
package/dist/index.js CHANGED
@@ -438,6 +438,8 @@ var STATUS_COLORS = {
438
438
  var UNKNOWN_STATUS_COLOR = "#9aa1ad";
439
439
 
440
440
  // src/element.ts
441
+ var warnedNoPrivate = /* @__PURE__ */ new WeakSet();
442
+ var PUBLIC_SETTLE_MS = 600;
441
443
  function countConnected(clients) {
442
444
  return clients.filter((c) => c.connected !== false).length;
443
445
  }
@@ -449,7 +451,15 @@ var OBSERVED = [
449
451
  "players",
450
452
  "max-players",
451
453
  "modal",
452
- "open"
454
+ "open",
455
+ // ---- M6 lane H: lobby front door ----
456
+ "quick-match",
457
+ "no-private",
458
+ "searching",
459
+ "waiting",
460
+ "waiting-of",
461
+ "hide-ready",
462
+ "queue"
453
463
  ];
454
464
  var STYLE = `
455
465
  :host {
@@ -520,6 +530,34 @@ var STYLE = `
520
530
  cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0;
521
531
  }
522
532
  .close:hover { background: #1e2129; }
533
+ /* ---- M6 lane H: lobby front door ---- */
534
+ .choose { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 2px; }
535
+ .choose button {
536
+ padding: 10px 12px; border-radius: 8px; border: 1px solid #2a2e37; cursor: pointer;
537
+ background: #1e2129; color: inherit; font: inherit; font-size: 14px; font-weight: 600;
538
+ text-align: center;
539
+ }
540
+ .choose button:hover { background: #262a33; }
541
+ .choose button.primary { background: #f2f3f5; color: #16181d; border-color: #f2f3f5; }
542
+ .choose button.primary:hover { background: #ffffff; }
543
+ .searching { text-align: center; font-size: 13px; color: #9aa1ad; padding: 18px 0 6px; }
544
+ .searching .count { display: block; margin-top: 6px; font-size: 12px; color: #6c7280; }
545
+ .roster { list-style: none; margin: 12px 0 0; padding: 0; display: flex;
546
+ flex-direction: column; gap: 4px; }
547
+ .roster li { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #cdd2da; }
548
+ .roster .flag { width: 8px; height: 8px; border-radius: 50%; background: #3a3f4a; flex: none; }
549
+ .roster li[data-ready='true'] .flag { background: #3ac177; }
550
+ .roster li[data-me='true'] { color: #f2f3f5; font-weight: 600; }
551
+ .lobby-note { margin-top: 10px; font-size: 12px; color: #9aa1ad; text-align: center; }
552
+ .ready {
553
+ margin-top: 10px; width: 100%; padding: 9px 12px; border-radius: 8px; border: 0;
554
+ cursor: pointer; background: #3ac177; color: #08130c; font: inherit; font-size: 13px;
555
+ font-weight: 700;
556
+ }
557
+ .ready[aria-pressed='true'] { background: #1e2129; color: #9aa1ad; }
558
+ .toggle { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 12px;
559
+ color: #9aa1ad; }
560
+ .toggle input { margin: 0; }
523
561
  `;
524
562
  var IrtLobbyElement = class extends HTMLElement {
525
563
  static get observedAttributes() {
@@ -586,6 +624,95 @@ var IrtLobbyElement = class extends HTMLElement {
586
624
  set maxPlayers(value) {
587
625
  this.setAttribute("max-players", String(value));
588
626
  }
627
+ // ---- M6 lane H: lobby front door ----
628
+ /**
629
+ * D75: the panel is the game's front door. Mirrors the `quick-match` boolean attribute.
630
+ *
631
+ * With it, and before a room is attached, the panel renders the chooser: **Start a private
632
+ * lobby** and **Join a game**. It also unlocks the searching state and the public toggle. Without
633
+ * it the panel is exactly what it has always been.
634
+ */
635
+ get quickMatch() {
636
+ return this.hasAttribute("quick-match");
637
+ }
638
+ set quickMatch(value) {
639
+ if (value) this.setAttribute("quick-match", "");
640
+ else this.removeAttribute("quick-match");
641
+ }
642
+ /**
643
+ * D75: this game has no private games. Mirrors the `no-private` boolean attribute.
644
+ *
645
+ * Only meaningful alongside `quick-match`: the chooser drops to **Join a game** alone and the
646
+ * code, link and QR never render, because there is nothing to share. On its own it is invalid —
647
+ * the panel renders as it always did and says so once on the console — because a panel with no
648
+ * private games and no quick match is a panel with no way in at all.
649
+ */
650
+ get noPrivate() {
651
+ return this.hasAttribute("no-private");
652
+ }
653
+ set noPrivate(value) {
654
+ if (value) this.setAttribute("no-private", "");
655
+ else this.removeAttribute("no-private");
656
+ }
657
+ /** D75: a quick match is in flight. Mirrors `searching`; `attachLobby` sets and clears it. */
658
+ get searching() {
659
+ return this.hasAttribute("searching");
660
+ }
661
+ set searching(value) {
662
+ if (value) this.setAttribute("searching", "");
663
+ else this.removeAttribute("searching");
664
+ }
665
+ /**
666
+ * D75: how many players are waiting in the queue, from `GET /match/status`.
667
+ *
668
+ * Rendered under "finding a game…" as `n of m waiting`, and only when both are set. Absent, the
669
+ * searching state is an honest spinner rather than a made-up count.
670
+ */
671
+ get waiting() {
672
+ if (!this.hasAttribute("waiting")) return void 0;
673
+ const value = Number(this.getAttribute("waiting"));
674
+ return Number.isFinite(value) ? value : void 0;
675
+ }
676
+ set waiting(value) {
677
+ if (value === void 0) this.removeAttribute("waiting");
678
+ else this.setAttribute("waiting", String(value));
679
+ }
680
+ /** D75: how many the queue takes. Mirrors `waiting-of`. */
681
+ get waitingOf() {
682
+ if (!this.hasAttribute("waiting-of")) return void 0;
683
+ const value = Number(this.getAttribute("waiting-of"));
684
+ return Number.isFinite(value) ? value : void 0;
685
+ }
686
+ set waitingOf(value) {
687
+ if (value === void 0) this.removeAttribute("waiting-of");
688
+ else this.setAttribute("waiting-of", String(value));
689
+ }
690
+ /**
691
+ * D75: the queue name the **Join a game** event carries. Mirrors the `queue` attribute.
692
+ *
693
+ * Empty means "the project's default queue", which is what a game with one queue wants and what
694
+ * omitting it has always meant everywhere else in the platform.
695
+ */
696
+ get queue() {
697
+ return this.getAttribute("queue") ?? "";
698
+ }
699
+ set queue(value) {
700
+ if (value === "") this.removeAttribute("queue");
701
+ else this.setAttribute("queue", value);
702
+ }
703
+ /**
704
+ * D75: draw the roster but not the ready button, for a game that draws its own.
705
+ *
706
+ * Mirrors `hide-ready`, and only bites under a `'when-ready'` room — the other two policies have
707
+ * no ready button to hide.
708
+ */
709
+ get hideReady() {
710
+ return this.hasAttribute("hide-ready");
711
+ }
712
+ set hideReady(value) {
713
+ if (value) this.setAttribute("hide-ready", "");
714
+ else this.removeAttribute("hide-ready");
715
+ }
589
716
  /** Whether the panel renders inside a fixed full-viewport backdrop. Mirrors `modal`. */
590
717
  get modal() {
591
718
  return this.hasAttribute("modal");
@@ -671,6 +798,8 @@ var IrtLobbyElement = class extends HTMLElement {
671
798
  this.players = countConnected(clients);
672
799
  })
673
800
  );
801
+ const lobby = room.lobby;
802
+ if (lobby) offs.push(lobby.on("change", () => this.render()));
674
803
  const detach = () => {
675
804
  for (const off of offs) off();
676
805
  if (this.#detach === detach) {
@@ -721,6 +850,12 @@ var IrtLobbyElement = class extends HTMLElement {
721
850
  } else {
722
851
  host = this.#root;
723
852
  }
853
+ const mode = this.#mode();
854
+ if (mode !== "panel" && this.roomCode === "") {
855
+ host.append(this.searching ? this.#searchingState(doc) : this.#chooser(doc, mode));
856
+ host.append(this.#badge(doc));
857
+ return;
858
+ }
724
859
  const status = this.status;
725
860
  const statusRow = doc.createElement("div");
726
861
  statusRow.className = "status";
@@ -742,6 +877,7 @@ var IrtLobbyElement = class extends HTMLElement {
742
877
  statusRow.append(playersSpan);
743
878
  }
744
879
  host.append(statusRow);
880
+ const sharing = mode !== "quick-match-only";
745
881
  const code = doc.createElement("button");
746
882
  code.type = "button";
747
883
  code.className = "code";
@@ -751,7 +887,7 @@ var IrtLobbyElement = class extends HTMLElement {
751
887
  code.addEventListener("click", () => {
752
888
  void this.#copy(this.roomCode, "room code copied");
753
889
  });
754
- host.append(code);
890
+ if (sharing) host.append(code);
755
891
  const link = doc.createElement("button");
756
892
  link.type = "button";
757
893
  link.className = "link";
@@ -761,16 +897,156 @@ var IrtLobbyElement = class extends HTMLElement {
761
897
  link.addEventListener("click", () => {
762
898
  void this.#copy(this.link, "link copied");
763
899
  });
764
- host.append(link);
900
+ if (sharing) host.append(link);
765
901
  const toast = doc.createElement("div");
766
902
  toast.className = "toast";
767
903
  host.append(toast);
768
- const canvas = doc.createElement("canvas");
769
- canvas.setAttribute("part", "qr");
770
- host.append(canvas);
771
- if (this.link) this.#drawQr(canvas, this.link);
772
- else canvas.hidden = true;
904
+ if (sharing) {
905
+ const canvas = doc.createElement("canvas");
906
+ canvas.setAttribute("part", "qr");
907
+ host.append(canvas);
908
+ if (this.link) this.#drawQr(canvas, this.link);
909
+ else canvas.hidden = true;
910
+ }
773
911
  if (this.nameEntry) host.append(this.#nameForm(doc));
912
+ this.#renderLobby(doc, host, mode);
913
+ host.append(this.#badge(doc));
914
+ }
915
+ // ---- M6 lane H: lobby front door ----
916
+ /**
917
+ * Which of the design's three attribute states this element is in.
918
+ *
919
+ * `no-private` alone is the invalid one, and the answer is deliberately today's panel plus one
920
+ * console warning rather than a throw or a silent reinterpretation: the element is a share panel
921
+ * a game already has on screen, and breaking it over an attribute pair would take a working
922
+ * feature away to punish a typo.
923
+ */
924
+ #mode() {
925
+ if (this.quickMatch) return this.noPrivate ? "quick-match-only" : "quick-match";
926
+ if (this.noPrivate && !warnedNoPrivate.has(this.ownerDocument)) {
927
+ warnedNoPrivate.add(this.ownerDocument);
928
+ console.warn(
929
+ "<irt-lobby>: no-private does nothing without quick-match \u2014 a panel with no private games and no quick match has no way into a game at all. Rendering the ordinary panel."
930
+ );
931
+ }
932
+ return "panel";
933
+ }
934
+ /** The pre-room chooser. It emits; it never joins. See the package header for why. */
935
+ #chooser(doc, mode) {
936
+ const wrap = doc.createElement("div");
937
+ wrap.className = "choose";
938
+ wrap.setAttribute("part", "choose");
939
+ const join = doc.createElement("button");
940
+ join.type = "button";
941
+ join.className = "primary";
942
+ join.setAttribute("part", "join");
943
+ join.textContent = "Join a game";
944
+ join.addEventListener("click", () => {
945
+ const queue = this.queue;
946
+ this.dispatchEvent(
947
+ new CustomEvent("quickmatch", {
948
+ detail: queue ? { queue } : {},
949
+ bubbles: true,
950
+ composed: true
951
+ })
952
+ );
953
+ });
954
+ if (mode === "quick-match") {
955
+ const priv = doc.createElement("button");
956
+ priv.type = "button";
957
+ priv.setAttribute("part", "private");
958
+ priv.textContent = "Start a private lobby";
959
+ priv.addEventListener("click", () => {
960
+ this.dispatchEvent(new CustomEvent("private", { bubbles: true, composed: true }));
961
+ });
962
+ wrap.append(priv);
963
+ }
964
+ wrap.append(join);
965
+ return wrap;
966
+ }
967
+ /** The searching state, with the queue's own numbers when the app has them. */
968
+ #searchingState(doc) {
969
+ const wrap = doc.createElement("div");
970
+ wrap.className = "searching";
971
+ wrap.setAttribute("part", "searching");
972
+ wrap.textContent = "finding a game\u2026";
973
+ const waiting = this.waiting;
974
+ const of = this.waitingOf;
975
+ if (waiting !== void 0 && of !== void 0) {
976
+ const count = doc.createElement("span");
977
+ count.className = "count";
978
+ count.setAttribute("part", "waiting");
979
+ count.textContent = `${waiting} of ${of} waiting`;
980
+ wrap.append(count);
981
+ }
982
+ return wrap;
983
+ }
984
+ /**
985
+ * The roster, the ready button and the public toggle, for a room that exposes a lobby.
986
+ *
987
+ * Three renderings, and which one appears is the ROOM's decision rather than an attribute's.
988
+ * `readyUi` is true only under a `'when-ready'` policy, and only then is readiness drawn at all;
989
+ * a room that starts when it is full says so and shows who has arrived; a `'manual'` room shows
990
+ * the roster alone, because only its own code knows what it is waiting for.
991
+ */
992
+ #renderLobby(doc, host, mode) {
993
+ const lobby = this.#room?.lobby;
994
+ if (!lobby) return;
995
+ if (lobby.phase === "started") return;
996
+ const players = lobby.players;
997
+ const list = doc.createElement("ul");
998
+ list.className = "roster";
999
+ list.setAttribute("part", "roster");
1000
+ for (const [index, player] of players.entries()) {
1001
+ const row = doc.createElement("li");
1002
+ row.dataset.ready = String(lobby.readyUi === true && player.ready === true);
1003
+ row.dataset.me = String(player.me === true);
1004
+ const flag = doc.createElement("span");
1005
+ flag.className = "flag";
1006
+ const label = doc.createElement("span");
1007
+ label.textContent = `player ${index + 1}${player.connected === false ? " (away)" : ""}`;
1008
+ row.append(flag, label);
1009
+ list.append(row);
1010
+ }
1011
+ host.append(list);
1012
+ const note = doc.createElement("div");
1013
+ note.className = "lobby-note";
1014
+ note.setAttribute("part", "lobby-note");
1015
+ const capacity = lobby.capacity ?? 0;
1016
+ note.textContent = lobby.readyUi === true ? `${players.filter((p) => p.ready).length} of ${players.length} ready` : capacity > 0 ? `${players.length}/${capacity} players, starting when full` : `${players.length} ${players.length === 1 ? "player" : "players"}`;
1017
+ host.append(note);
1018
+ if (lobby.readyUi === true && !this.hideReady) {
1019
+ const mine = players.find((p) => p.me === true);
1020
+ const isReady = mine?.ready === true;
1021
+ const button = doc.createElement("button");
1022
+ button.type = "button";
1023
+ button.className = "ready";
1024
+ button.setAttribute("part", "ready");
1025
+ button.setAttribute("aria-pressed", String(isReady));
1026
+ button.textContent = isReady ? "not ready" : "ready";
1027
+ button.addEventListener("click", () => lobby.ready(!isReady));
1028
+ host.append(button);
1029
+ }
1030
+ if (mode === "quick-match" && lobby.setPublic) {
1031
+ const wrap = doc.createElement("label");
1032
+ wrap.className = "toggle";
1033
+ wrap.setAttribute("part", "public-toggle");
1034
+ const box = doc.createElement("input");
1035
+ box.type = "checkbox";
1036
+ box.checked = lobby.public === true;
1037
+ box.addEventListener("change", () => {
1038
+ box.disabled = true;
1039
+ lobby.setPublic?.(box.checked);
1040
+ setTimeout(() => this.render(), PUBLIC_SETTLE_MS);
1041
+ });
1042
+ const text = doc.createElement("span");
1043
+ text.textContent = "anyone can join";
1044
+ wrap.append(box, text);
1045
+ host.append(wrap);
1046
+ }
1047
+ }
1048
+ /** The badge, unchanged; extracted so the chooser can render it too. */
1049
+ #badge(doc) {
774
1050
  const badge = doc.createElement("div");
775
1051
  badge.className = "badge";
776
1052
  badge.setAttribute("part", "badge");
@@ -780,7 +1056,7 @@ var IrtLobbyElement = class extends HTMLElement {
780
1056
  anchor.rel = "noreferrer";
781
1057
  anchor.textContent = "multiplayer by irt.io";
782
1058
  badge.append(anchor);
783
- host.append(badge);
1059
+ return badge;
784
1060
  }
785
1061
  /** The optional name box. Submitting emits `name` **before** the app joins, which is the point. */
786
1062
  #nameForm(doc) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/lobby",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "<irt-lobby>: room code, share link, QR, connection status, name entry",
5
5
  "license": "MIT",
6
6
  "publishConfig": {