srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/multiplayer.js
blob: ee99e6c39e9c5e2e24df9b6c38d22c633af41d66 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import { api } from './api.js';
import { detectDeviceType } from './deviceDetect.js';

// Same base-URL resolution as api.js (window.TYPERPUNK_API_URL override for
// local dev, same-origin otherwise), just converted to a ws:// URL instead
// of being handed to fetch.
function wsBaseUrl() {
    const httpBase = (typeof window !== 'undefined' && window.TYPERPUNK_API_URL) || window.location.origin;
    return httpBase.replace(/^http/, 'ws');
}

export async function createRoom(deviceFilter) {
    const { room_code } = await api.post('/api/multiplayer/rooms', deviceFilter ? { device_filter: deviceFilter } : {});
    return room_code;
}

// Auto-matchmaking. The server hands back whichever room is still filling,
// or opens a new one when none is - so the player never sees a room code.
// createRoom stays for the deliberate "race my friends on this code" path.
export async function quickMatch(deviceFilter) {
    const { room_code } = await api.post('/api/multiplayer/quickmatch', deviceFilter ? { device_filter: deviceFilter } : {});
    return room_code;
}

// Pub-sub rather than fixed callbacks passed to connectToRoom - the lobby
// screen and the typing screen both need to react to the same connection's
// messages (player list / countdown during the lobby phase, progress /
// finished during the race), and they're two different pieces of code that
// each attach their own listeners as the room moves between phases, rather
// than one caller owning every handler up front.
export function connectToRoom(roomCode, playerName) {
    const ws = new WebSocket(`${wsBaseUrl()}/ws/multiplayer/${encodeURIComponent(roomCode)}`);
    const listeners = new Map();
    const connection = { playerId: null };

    function emit(event, ...args) {
        for (const fn of listeners.get(event) || []) fn(...args);
    }

    ws.addEventListener('open', () => {
        ws.send(JSON.stringify({ type: 'Join', name: playerName, device_type: detectDeviceType() }));
    });

    ws.addEventListener('message', event => {
        let msg;
        try { msg = JSON.parse(event.data); } catch { return; }
        switch (msg.type) {
            case 'Joined': connection.playerId = msg.player_id; emit('joined', msg.player_id); break;
            case 'PlayerList': emit('playerList', msg.players); break;
            case 'RaceText': emit('raceText', msg.text, msg.attribution, msg.category); break;
            case 'Countdown': emit('countdown', msg.seconds); break;
            case 'Start': emit('start', msg.text); break;
            case 'PlayerProgress': emit('playerProgress', msg.player_id, msg.percent, msg.wpm); break;
            case 'PlayerFinished': emit('playerFinished', msg.player_id, msg.wpm, msg.accuracy, msg.time, msg.place); break;
            case 'RoomClosed': emit('roomClosed', msg.reason); break;
            case 'Error': emit('error', msg.message); break;
        }
    });

    ws.addEventListener('close', () => emit('close'));
    ws.addEventListener('error', () => emit('error', 'connection error'));

    Object.assign(connection, {
        on(event, fn) {
            if (!listeners.has(event)) listeners.set(event, new Set());
            listeners.get(event).add(fn);
            return () => listeners.get(event)?.delete(fn);
        },
        ready: () => ws.send(JSON.stringify({ type: 'Ready' })),
        sendProgress: (percent, wpm) => ws.send(JSON.stringify({ type: 'Progress', percent, wpm })),
        sendFinish: (wpm, accuracy, time) => ws.send(JSON.stringify({ type: 'Finish', wpm, accuracy, time })),
        close: () => ws.close(),
    });
    return connection;
}