diff --git a/README.md b/README.md index 8e4496d..bab7db2 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ rather than against each other's assumptions. Regenerate the fixtures with A player creates a room and gets a six-character code and an invite link. The alphabet omits `0`/`O` and `1`/`I`/`L`, because these codes get read aloud. The others type the code or open the link, which seats them in the room's lobby. +Quick-match is the other way in: it pairs a player with the next stranger who +also asked, with no code to share, and the first game between them starts by +itself rather than waiting on readiness and a start button. A room holds up to four people and needs two to start. Both numbers are server constants sent to the client in `RoomState`, so the lobby draws whatever the diff --git a/web/src/lib/i18n/vi.js b/web/src/lib/i18n/vi.js index 66e60f7..4db7973 100644 --- a/web/src/lib/i18n/vi.js +++ b/web/src/lib/i18n/vi.js @@ -89,6 +89,14 @@ export const t = { onlineIntro: 'Tạo phòng rồi gửi mã cho bạn bè, hoặc nhập mã bạn được mời.', createRoom: 'Tạo phòng', joinRoom: 'Vào phòng', + + // Quick-match: paired with the next stranger who also asked, no code + // exchanged. + quickMatch: 'Chơi ngay', + quickMatchWaiting: 'Đang tìm đối thủ… {n} giây', + quickMatchCancel: 'Hủy tìm trận', + quickMatchNudge: 'Chưa có ai chờ…', + quickMatchNudgeLink: 'Chơi với máy trong lúc đợi?', roomCodeLabel: 'Mã phòng', roomCodePlaceholder: 'VD: K7M2QP', roomCodeHint: 'Sáu ký tự. Không phân biệt hoa thường.', @@ -192,7 +200,7 @@ export const t = { rulesRoomTitle: 'Mã phòng và sẵn sàng', rulesRoomBody: - 'Người tạo phòng nhận một mã sáu ký tự để gửi cho bạn bè — bảng chữ cái của mã bỏ 0/O và 1/I/L vì mã này thường được đọc thành tiếng cho nhau nghe. Phòng cần mọi người trừ chủ phòng bấm sẵn sàng rồi chủ phòng mới bấm bắt đầu — chủ phòng không có nút sẵn sàng riêng, vì bấm bắt đầu đã là lời xác nhận của họ.', + 'Người tạo phòng nhận một mã sáu ký tự để gửi cho bạn bè — bảng chữ cái của mã bỏ 0/O và 1/I/L vì mã này thường được đọc thành tiếng cho nhau nghe. Phòng cần mọi người trừ chủ phòng bấm sẵn sàng rồi chủ phòng mới bấm bắt đầu — chủ phòng không có nút sẵn sàng riêng, vì bấm bắt đầu đã là lời xác nhận của họ. Không có ai để gửi mã thì bấm "Chơi ngay": bạn được ghép với người lạ kế tiếp cũng đang chờ, và ván đầu tiên tự bắt đầu, không cần ai bấm sẵn sàng hay bắt đầu.', rulesReconnectTitle: 'Mất kết nối', rulesReconnectBody: @@ -248,6 +256,8 @@ export const endReasonMessages = { */ export const errorMessages = { already_greeted: 'Phiên chơi đã được mở rồi.', + already_in_a_room: 'Bạn đang ở trong một phòng khác.', + already_queued: 'Bạn đang chờ ghép trận rồi.', bad_frame: 'Máy chủ không đọc được dữ liệu gửi lên.', busy: 'Máy chủ đang bận. Hãy thử lại.', cannot_join_own_room: 'Bạn không thể vào phòng của chính mình.', diff --git a/web/src/lib/stores/game.svelte.js b/web/src/lib/stores/game.svelte.js index 157ba0f..f89093a 100644 --- a/web/src/lib/stores/game.svelte.js +++ b/web/src/lib/stores/game.svelte.js @@ -76,6 +76,13 @@ function initialState() { nickname: '', roomCode: '', + /** + * Waiting in the quick-match queue for the next stranger who also + * asked. Ends on its own once a `RoomState` seats this connection + * somewhere, so nothing else has to clear it by hand. + */ + queued: false, + /** * The room, exactly as the server last described it. Every field is * server-owned: the client never decides who is seated, who owns the @@ -246,6 +253,9 @@ export function createGameStore() { break; case 'roomState': + // A room existing is proof the wait is over, whether or not a + // quickMatchStatus already said so. + state.queued = false; // One snapshot, applied wholesale. Merging fields selectively // is how a client ends up believing a mixture of two states // the server was never in. @@ -418,6 +428,10 @@ export function createGameStore() { state.error = errorMessage(value.code); break; + case 'quickMatchStatus': + state.queued = value.queued; + break; + case 'pong': // Handled by the transport, which owns the clock offset. break; diff --git a/web/src/lib/ws/messages.js b/web/src/lib/ws/messages.js index f54b6af..228d984 100644 --- a/web/src/lib/ws/messages.js +++ b/web/src/lib/ws/messages.js @@ -1,5 +1,6 @@ import { create } from '@bufbuild/protobuf'; import { + CancelQuickMatchSchema, ClientMessageSchema, CreateRoomSchema, HelloSchema, @@ -7,6 +8,7 @@ import { KickPlayerSchema, LeaveRoomSchema, PingSchema, + QuickMatchSchema, ResignSchema, SendChatSchema, SetReadySchema, @@ -130,6 +132,24 @@ export function resign() { }); } +/** + * Asks to be paired with the next stranger who also asks. Answered with + * QuickMatchStatus either way: queued while nobody else is waiting, or not + * once a room has been opened for the pair. + */ +export function quickMatch() { + return create(ClientMessageSchema, { + payload: { case: 'quickMatch', value: create(QuickMatchSchema, {}) } + }); +} + +/** Leaves the pairing queue. Safe to send whether or not it is still waiting. */ +export function cancelQuickMatch() { + return create(ClientMessageSchema, { + payload: { case: 'cancelQuickMatch', value: create(CancelQuickMatchSchema, {}) } + }); +} + /** @param {number} clientTimeMs */ export function ping(clientTimeMs) { return create(ClientMessageSchema, { diff --git a/web/src/routes/online/+page.svelte b/web/src/routes/online/+page.svelte index 2319560..a9d8853 100644 --- a/web/src/routes/online/+page.svelte +++ b/web/src/routes/online/+page.svelte @@ -15,10 +15,12 @@ import { game } from '$lib/stores/game.svelte.js'; import { settings } from '$lib/stores/settings.svelte.js'; import { + cancelQuickMatch, createRoom, joinRoom, kickPlayer, leaveRoom, + quickMatch, resign, sendChat, setReady, @@ -39,10 +41,25 @@ * What the player asked for, held until the socket can carry it. Same shape * as the bot screen's request latch and for the same reason: a request is * something the player did, not a condition to be re-derived from the board. - * @type {{ kind: 'create' } | { kind: 'join', code: string } | null} + * @type {{ kind: 'create' } | { kind: 'join', code: string } | { kind: 'quickMatch' } | null} */ let pending = $state(null); + /** + * How long a quick match has been waiting, in whole seconds. Client-only + * and approximate on purpose — this is a "still looking" indicator, not + * the turn clock, so it is timed off the device rather than the server's + * estimated time. + */ + let queuedForS = $state(0); + + /** + * How long the wait runs before the screen offers the bot instead. A + * quick match with nobody to pair with would otherwise be a dead end of + * its own. + */ + const QUICK_MATCH_NUDGE_S = 20; + /** * How long a held request waits before the screen stops saying "connecting" * and starts saying something the player can act on. The backoff is capped @@ -175,6 +192,9 @@ send(leaveRoom()); forgetSession(); } + // Leaving mid-wait is leaving the queue too: nobody is left to pair + // with a tab that has gone. + if (game.state.queued) send(cancelQuickMatch()); pending = null; disconnect(); game.reset(); @@ -188,6 +208,20 @@ untrack(() => flush(open)); }); + // Counts up while queued, for the waiting panel's elapsed time and the + // bot nudge. Restarted from zero each time the wait begins, so a match + // found and then a later, separate wait never inherits the first one's + // clock. + $effect(() => { + if (!game.state.queued) { + queuedForS = 0; + return; + } + queuedForS = 0; + const id = setInterval(() => (queuedForS += 1), 1000); + return () => clearInterval(id); + }); + // A request that has been waiting on a socket for longer than a player will // believe. Timed from the request rather than from the status, because a // backoff cycles between "reconnecting" and "connecting" indefinitely and @@ -223,7 +257,7 @@ }); }); - /** @param {{ kind: 'create' } | { kind: 'join', code: string }} req */ + /** @param {{ kind: 'create' } | { kind: 'join', code: string } | { kind: 'quickMatch' }} req */ function request(req) { codeError = ''; resuming = false; @@ -244,7 +278,17 @@ if (!pending || !isOpen || resuming) return; // Cleared only once the socket has taken it, so a request made during a // reconnect is carried by the next open connection rather than lost. - const sent = pending.kind === 'create' ? send(createRoom()) : send(joinRoom(pending.code)); + let sent; + switch (pending.kind) { + case 'create': + sent = send(createRoom()); + break; + case 'quickMatch': + sent = send(quickMatch()); + break; + default: + sent = send(joinRoom(pending.code)); + } if (sent) pending = null; } @@ -265,6 +309,16 @@ request({ kind: 'create' }); } + function playQuickMatch() { + request({ kind: 'quickMatch' }); + } + + /** Withdraws from the pairing queue without leaving the page. */ + function cancelQueue() { + send(cancelQuickMatch()); + pending = null; + } + function goHome() { goto('/'); } @@ -410,41 +464,61 @@
{t.connectStalled}
{/if} - - - -