04-3. WebSocket 實戰應用

從聊天室到協作編輯,實作完整的即時通訊系統

🚀 WebSocket 實戰應用

🎯 實戰專案概覽

本篇將實作四個完整的 WebSocket 應用:

  1. 即時聊天室 💬
  2. 即時通知系統 🔔
  3. 協作編輯器 ✏️
  4. 多人遊戲同步 🎮

💬 專案一:即時聊天室

需求分析

功能:
✅ 多個使用者同時聊天
✅ 顯示線上人數
✅ 顯示「正在輸入...」
✅ 訊息歷史記錄
✅ 私訊功能

後端實作(Node.js + ws)

// server.js
const WebSocket = require('ws');
const http = require('http');
const express = require('express');

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

// 儲存所有連線
const clients = new Map();

// 訊息歷史(實務應該用資料庫)
const messageHistory = [];

wss.on('connection', (ws) => {
    let userId = null;

    console.log('新使用者連線');

    ws.on('message', (message) => {
        const data = JSON.parse(message);

        switch (data.type) {
            case 'join':
                handleJoin(ws, data);
                break;

            case 'message':
                handleMessage(ws, data);
                break;

            case 'typing':
                handleTyping(ws, data);
                break;

            case 'private':
                handlePrivateMessage(ws, data);
                break;
        }
    });

    ws.on('close', () => {
        handleDisconnect(ws);
    });

    // 加入聊天室
    function handleJoin(ws, data) {
        userId = data.userId;
        const username = data.username;

        clients.set(userId, {
            ws: ws,
            username: username
        });

        // 發送歷史訊息給新使用者
        ws.send(JSON.stringify({
            type: 'history',
            messages: messageHistory
        }));

        // 廣播使用者加入
        broadcast({
            type: 'user_joined',
            userId: userId,
            username: username,
            onlineCount: clients.size
        });

        console.log(`${username} 加入聊天室`);
    }

    // 處理聊天訊息
    function handleMessage(ws, data) {
        const message = {
            type: 'message',
            userId: userId,
            username: clients.get(userId).username,
            content: data.content,
            timestamp: new Date().toISOString()
        };

        // 儲存訊息歷史
        messageHistory.push(message);
        if (messageHistory.length > 100) {
            messageHistory.shift();  // 只保留最新 100 則
        }

        // 廣播給所有人
        broadcast(message);
    }

    // 處理「正在輸入」
    function handleTyping(ws, data) {
        broadcast({
            type: 'typing',
            userId: userId,
            username: clients.get(userId).username,
            isTyping: data.isTyping
        }, userId);  // 排除自己
    }

    // 私訊
    function handlePrivateMessage(ws, data) {
        const targetClient = clients.get(data.targetUserId);

        if (targetClient) {
            const message = {
                type: 'private',
                fromUserId: userId,
                fromUsername: clients.get(userId).username,
                content: data.content,
                timestamp: new Date().toISOString()
            };

            // 發送給目標使用者
            targetClient.ws.send(JSON.stringify(message));

            // 也發送給自己(顯示已發送)
            ws.send(JSON.stringify({
                ...message,
                type: 'private_sent'
            }));
        }
    }

    // 使用者離線
    function handleDisconnect(ws) {
        if (userId && clients.has(userId)) {
            const username = clients.get(userId).username;
            clients.delete(userId);

            broadcast({
                type: 'user_left',
                userId: userId,
                username: username,
                onlineCount: clients.size
            });

            console.log(`${username} 離開聊天室`);
        }
    }

    // 廣播訊息
    function broadcast(message, excludeUserId = null) {
        const messageStr = JSON.stringify(message);

        clients.forEach((client, id) => {
            if (id !== excludeUserId && client.ws.readyState === WebSocket.OPEN) {
                client.ws.send(messageStr);
            }
        });
    }
});

// 靜態檔案
app.use(express.static('public'));

server.listen(3000, () => {
    console.log('聊天室伺服器啟動於 http://localhost:3000');
});

前端實作(HTML + JavaScript)

<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
    <title>即時聊天室</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }

        #chat-box {
            border: 1px solid #ccc;
            height: 400px;
            overflow-y: scroll;
            padding: 10px;
            margin-bottom: 10px;
            background-color: #f9f9f9;
        }

        .message {
            margin-bottom: 10px;
            padding: 8px;
            border-radius: 5px;
        }

        .message.self {
            background-color: #d1e7dd;
            text-align: right;
        }

        .message.other {
            background-color: #fff;
        }

        .message.system {
            background-color: #fff3cd;
            text-align: center;
            font-style: italic;
        }

        .typing-indicator {
            color: #666;
            font-style: italic;
            font-size: 0.9em;
        }

        #input-area {
            display: flex;
            gap: 10px;
        }

        #message-input {
            flex: 1;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 5px;
        }

        #send-btn {
            padding: 10px 20px;
            background-color: #007bff;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
        }

        #send-btn:hover {
            background-color: #0056b3;
        }

        #online-count {
            color: #28a745;
            font-weight: bold;
        }
    </style>
</head>
<body>
    <h1>即時聊天室 💬</h1>
    <p>線上人數:<span id="online-count">0</span></p>

    <div id="chat-box"></div>

    <div class="typing-indicator" id="typing-indicator"></div>

    <div id="input-area">
        <input type="text" id="message-input" placeholder="輸入訊息..." />
        <button id="send-btn">發送</button>
    </div>

    <script>
        const chatBox = document.getElementById('chat-box');
        const messageInput = document.getElementById('message-input');
        const sendBtn = document.getElementById('send-btn');
        const onlineCount = document.getElementById('online-count');
        const typingIndicator = document.getElementById('typing-indicator');

        // 生成隨機使用者 ID 和名稱
        const userId = 'user_' + Math.random().toString(36).substr(2, 9);
        const username = 'User_' + Math.floor(Math.random() * 1000);

        // 建立 WebSocket 連線
        const ws = new WebSocket('ws://localhost:3000');

        ws.onopen = () => {
            console.log('已連線到聊天室');

            // 加入聊天室
            ws.send(JSON.stringify({
                type: 'join',
                userId: userId,
                username: username
            }));
        };

        ws.onmessage = (event) => {
            const data = JSON.parse(event.data);

            switch (data.type) {
                case 'history':
                    // 顯示歷史訊息
                    data.messages.forEach(msg => displayMessage(msg));
                    break;

                case 'message':
                    displayMessage(data);
                    break;

                case 'user_joined':
                    displaySystemMessage(`${data.username} 加入聊天室`);
                    updateOnlineCount(data.onlineCount);
                    break;

                case 'user_left':
                    displaySystemMessage(`${data.username} 離開聊天室`);
                    updateOnlineCount(data.onlineCount);
                    break;

                case 'typing':
                    showTypingIndicator(data);
                    break;

                case 'private':
                    displayPrivateMessage(data, 'received');
                    break;

                case 'private_sent':
                    displayPrivateMessage(data, 'sent');
                    break;
            }
        };

        ws.onerror = (error) => {
            console.error('WebSocket 錯誤:', error);
        };

        ws.onclose = () => {
            console.log('連線已關閉');
            displaySystemMessage('連線已中斷');
        };

        // 發送訊息
        function sendMessage() {
            const content = messageInput.value.trim();

            if (content) {
                ws.send(JSON.stringify({
                    type: 'message',
                    content: content
                }));

                messageInput.value = '';
                stopTyping();
            }
        }

        sendBtn.addEventListener('click', sendMessage);

        messageInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                sendMessage();
            }
        });

        // 「正在輸入」功能
        let typingTimeout;

        messageInput.addEventListener('input', () => {
            if (messageInput.value.trim()) {
                startTyping();

                // 3 秒後自動停止
                clearTimeout(typingTimeout);
                typingTimeout = setTimeout(stopTyping, 3000);
            } else {
                stopTyping();
            }
        });

        function startTyping() {
            ws.send(JSON.stringify({
                type: 'typing',
                isTyping: true
            }));
        }

        function stopTyping() {
            ws.send(JSON.stringify({
                type: 'typing',
                isTyping: false
            }));
        }

        // 顯示訊息
        function displayMessage(data) {
            const messageDiv = document.createElement('div');
            messageDiv.className = 'message ' + (data.userId === userId ? 'self' : 'other');

            const time = new Date(data.timestamp).toLocaleTimeString();

            messageDiv.innerHTML = `
                <strong>${data.username}</strong>
                <span style="color: #999; font-size: 0.8em;">${time}</span>
                <div>${escapeHtml(data.content)}</div>
            `;

            chatBox.appendChild(messageDiv);
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        // 顯示系統訊息
        function displaySystemMessage(content) {
            const messageDiv = document.createElement('div');
            messageDiv.className = 'message system';
            messageDiv.textContent = content;

            chatBox.appendChild(messageDiv);
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        // 顯示私訊
        function displayPrivateMessage(data, direction) {
            const messageDiv = document.createElement('div');
            messageDiv.className = 'message ' + (direction === 'sent' ? 'self' : 'other');

            const prefix = direction === 'sent' ? '私訊給' : '來自';

            messageDiv.innerHTML = `
                <strong>${prefix} ${data.fromUsername}</strong>
                <div>${escapeHtml(data.content)}</div>
            `;

            chatBox.appendChild(messageDiv);
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        // 更新線上人數
        function updateOnlineCount(count) {
            onlineCount.textContent = count;
        }

        // 顯示「正在輸入」
        const typingUsers = new Set();

        function showTypingIndicator(data) {
            if (data.isTyping) {
                typingUsers.add(data.username);
            } else {
                typingUsers.delete(data.username);
            }

            if (typingUsers.size > 0) {
                const users = Array.from(typingUsers).join(', ');
                typingIndicator.textContent = `${users} 正在輸入...`;
            } else {
                typingIndicator.textContent = '';
            }
        }

        // HTML 跳脫(防 XSS)
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }
    </script>
</body>
</html>

🔔 專案二:即時通知系統

後端實作(Python + Flask-SocketIO)

# app.py
from flask import Flask, render_template
from flask_socketio import SocketIO, emit, join_room, leave_room
import time
from threading import Thread

app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!'
socketio = SocketIO(app, cors_allowed_origins="*")

# 使用者訂閱的通知類型
user_subscriptions = {}

@socketio.on('connect')
def handle_connect():
    print(f'使用者連線:{request.sid}')
    emit('connected', {'message': '已連線到通知系統'})

@socketio.on('subscribe')
def handle_subscribe(data):
    """訂閱通知類型"""
    user_id = data['userId']
    notification_types = data['types']  # ['order', 'message', 'system']

    user_subscriptions[user_id] = {
        'sid': request.sid,
        'types': notification_types
    }

    # 加入對應的房間
    for ntype in notification_types:
        join_room(ntype)

    emit('subscribed', {'types': notification_types})
    print(f'{user_id} 訂閱:{notification_types}')

@socketio.on('unsubscribe')
def handle_unsubscribe(data):
    """取消訂閱"""
    notification_type = data['type']
    leave_room(notification_type)

    emit('unsubscribed', {'type': notification_type})

@socketio.on('disconnect')
def handle_disconnect():
    print(f'使用者離線:{request.sid}')

    # 清理訂閱
    for user_id, sub in list(user_subscriptions.items()):
        if sub['sid'] == request.sid:
            del user_subscriptions[user_id]
            break

# 發送通知的 API
@app.route('/api/notify/<notification_type>/<user_id>', methods=['POST'])
def send_notification(notification_type, user_id):
    """發送通知給特定使用者"""
    from flask import request as flask_request

    data = flask_request.json

    if user_id in user_subscriptions:
        sub = user_subscriptions[user_id]

        if notification_type in sub['types']:
            socketio.emit('notification', {
                'type': notification_type,
                'title': data.get('title'),
                'message': data.get('message'),
                'timestamp': time.time()
            }, room=sub['sid'])

            return {'status': 'sent'}, 200

    return {'status': 'user not subscribed'}, 404

# 廣播通知
@app.route('/api/broadcast/<notification_type>', methods=['POST'])
def broadcast_notification(notification_type):
    """廣播通知給所有訂閱者"""
    from flask import request as flask_request

    data = flask_request.json

    socketio.emit('notification', {
        'type': notification_type,
        'title': data.get('title'),
        'message': data.get('message'),
        'timestamp': time.time()
    }, room=notification_type)

    return {'status': 'broadcasted'}, 200

# 模擬定期發送系統通知
def send_periodic_notifications():
    while True:
        time.sleep(30)  # 每 30 秒

        socketio.emit('notification', {
            'type': 'system',
            'title': '系統通知',
            'message': '這是定期系統通知',
            'timestamp': time.time()
        }, room='system')

# 啟動背景執行緒
Thread(target=send_periodic_notifications, daemon=True).start()

if __name__ == '__main__':
    socketio.run(app, debug=True, port=5000)

前端實作

<!-- templates/notifications.html -->
<!DOCTYPE html>
<html>
<head>
    <title>即時通知系統</title>
    <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
    <style>
        #notifications {
            position: fixed;
            top: 20px;
            right: 20px;
            width: 300px;
        }

        .notification {
            background-color: #fff;
            border-left: 4px solid #007bff;
            padding: 15px;
            margin-bottom: 10px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
            border-radius: 5px;
            animation: slideIn 0.3s ease;
        }

        .notification.order { border-left-color: #28a745; }
        .notification.message { border-left-color: #17a2b8; }
        .notification.system { border-left-color: #ffc107; }

        @keyframes slideIn {
            from {
                transform: translateX(100%);
                opacity: 0;
            }
            to {
                transform: translateX(0);
                opacity: 1;
            }
        }

        .notification-title {
            font-weight: bold;
            margin-bottom: 5px;
        }

        .notification-close {
            float: right;
            cursor: pointer;
            color: #999;
        }
    </style>
</head>
<body>
    <h1>即時通知系統 🔔</h1>

    <h3>訂閱通知類型:</h3>
    <label>
        <input type="checkbox" id="sub-order" value="order" checked />
        訂單通知
    </label>
    <label>
        <input type="checkbox" id="sub-message" value="message" checked />
        訊息通知
    </label>
    <label>
        <input type="checkbox" id="sub-system" value="system" checked />
        系統通知
    </label>
    <button id="update-subscription">更新訂閱</button>

    <div id="notifications"></div>

    <script>
        const userId = 'user_' + Math.random().toString(36).substr(2, 9);
        const socket = io('http://localhost:5000');

        socket.on('connect', () => {
            console.log('已連線');

            // 訂閱通知
            updateSubscription();
        });

        socket.on('notification', (data) => {
            showNotification(data);
        });

        // 更新訂閱
        document.getElementById('update-subscription').addEventListener('click', updateSubscription);

        function updateSubscription() {
            const types = [];

            if (document.getElementById('sub-order').checked) types.push('order');
            if (document.getElementById('sub-message').checked) types.push('message');
            if (document.getElementById('sub-system').checked) types.push('system');

            socket.emit('subscribe', {
                userId: userId,
                types: types
            });

            console.log('已訂閱:', types);
        }

        // 顯示通知
        function showNotification(data) {
            const container = document.getElementById('notifications');

            const notification = document.createElement('div');
            notification.className = `notification ${data.type}`;

            notification.innerHTML = `
                <span class="notification-close" onclick="this.parentElement.remove()">✖</span>
                <div class="notification-title">${data.title}</div>
                <div>${data.message}</div>
                <div style="color: #999; font-size: 0.8em; margin-top: 5px;">
                    ${new Date(data.timestamp * 1000).toLocaleTimeString()}
                </div>
            `;

            container.insertBefore(notification, container.firstChild);

            // 5 秒後自動關閉
            setTimeout(() => {
                notification.style.opacity = '0';
                setTimeout(() => notification.remove(), 300);
            }, 5000);

            // 播放聲音(可選)
            playNotificationSound();
        }

        function playNotificationSound() {
            // 使用 Web Audio API 播放提示音
            const audioContext = new (window.AudioContext || window.webkitAudioContext)();
            const oscillator = audioContext.createOscillator();
            const gainNode = audioContext.createGain();

            oscillator.connect(gainNode);
            gainNode.connect(audioContext.destination);

            oscillator.frequency.value = 800;
            oscillator.type = 'sine';

            gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
            gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);

            oscillator.start(audioContext.currentTime);
            oscillator.stop(audioContext.currentTime + 0.1);
        }
    </script>
</body>
</html>

觸發通知(測試)

# 發送訂單通知給特定使用者
curl -X POST http://localhost:5000/api/notify/order/user_abc \
  -H "Content-Type: application/json" \
  -d '{"title": "訂單已出貨", "message": "您的訂單 #12345 已出貨"}'

# 廣播系統通知
curl -X POST http://localhost:5000/api/broadcast/system \
  -H "Content-Type: application/json" \
  -d '{"title": "系統維護", "message": "系統將於今晚 22:00 維護"}'

✏️ 專案三:協作編輯器(簡化版 Google Docs)

後端實作(Node.js + Operational Transform)

// collaborative-server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

// 儲存文件內容
let documentContent = '';

// 儲存操作歷史(簡化版)
const operationHistory = [];

wss.on('connection', (ws) => {
    console.log('新使用者連線');

    // 發送當前文件內容
    ws.send(JSON.stringify({
        type: 'init',
        content: documentContent
    }));

    ws.on('message', (message) => {
        const data = JSON.parse(message);

        if (data.type === 'edit') {
            handleEdit(ws, data);
        }
    });
});

function handleEdit(ws, data) {
    const { operation, content } = data;

    // 應用操作到文件
    documentContent = content;

    // 記錄操作
    operationHistory.push({
        operation: operation,
        timestamp: Date.now()
    });

    // 廣播給其他使用者
    wss.clients.forEach((client) => {
        if (client !== ws && client.readyState === WebSocket.OPEN) {
            client.send(JSON.stringify({
                type: 'update',
                operation: operation,
                content: content
            }));
        }
    });
}

console.log('協作編輯伺服器啟動於 ws://localhost:8080');

前端實作

<!DOCTYPE html>
<html>
<head>
    <title>協作編輯器</title>
    <style>
        #editor {
            width: 100%;
            height: 400px;
            padding: 10px;
            font-family: monospace;
            font-size: 14px;
            border: 1px solid #ccc;
        }

        #status {
            color: #28a745;
            margin: 10px 0;
        }
    </style>
</head>
<body>
    <h1>協作編輯器 ✏️</h1>
    <div id="status">連線中...</div>

    <textarea id="editor" placeholder="開始輸入..."></textarea>

    <script>
        const editor = document.getElementById('editor');
        const status = document.getElementById('status');

        const ws = new WebSocket('ws://localhost:8080');

        let isUpdating = false;

        ws.onopen = () => {
            status.textContent = '✅ 已連線';
        };

        ws.onmessage = (event) => {
            const data = JSON.parse(event.data);

            if (data.type === 'init') {
                // 初始化文件內容
                editor.value = data.content;
            } else if (data.type === 'update') {
                // 其他使用者的更新
                isUpdating = true;

                // 儲存游標位置
                const cursorPosition = editor.selectionStart;

                editor.value = data.content;

                // 恢復游標位置
                editor.setSelectionRange(cursorPosition, cursorPosition);

                isUpdating = false;
            }
        };

        // 監聽輸入
        let updateTimeout;

        editor.addEventListener('input', () => {
            if (isUpdating) return;

            // 防抖:500ms 後才發送
            clearTimeout(updateTimeout);

            updateTimeout = setTimeout(() => {
                ws.send(JSON.stringify({
                    type: 'edit',
                    operation: {
                        // 簡化版,實際應該用 Operational Transform
                        type: 'replace',
                        content: editor.value
                    },
                    content: editor.value
                }));
            }, 500);
        });

        ws.onerror = (error) => {
            status.textContent = '❌ 連線錯誤';
            console.error(error);
        };

        ws.onclose = () => {
            status.textContent = '⚠️ 連線已中斷';
        };
    </script>
</body>
</html>

🎮 專案四:多人遊戲同步(簡單的多人移動遊戲)

完整實作

// game-server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 9000 });

// 遊戲狀態
const players = new Map();

// 遊戲循環(60 FPS)
setInterval(() => {
    broadcastGameState();
}, 1000 / 60);

wss.on('connection', (ws) => {
    const playerId = generateId();

    // 新玩家加入
    players.set(playerId, {
        ws: ws,
        x: Math.random() * 800,
        y: Math.random() * 600,
        color: randomColor()
    });

    // 發送玩家 ID
    ws.send(JSON.stringify({
        type: 'init',
        playerId: playerId,
        players: serializePlayers()
    }));

    // 廣播新玩家加入
    broadcast({
        type: 'player_joined',
        playerId: playerId,
        player: players.get(playerId)
    }, playerId);

    ws.on('message', (message) => {
        const data = JSON.parse(message);

        if (data.type === 'move') {
            handleMove(playerId, data);
        }
    });

    ws.on('close', () => {
        players.delete(playerId);

        broadcast({
            type: 'player_left',
            playerId: playerId
        });
    });
});

function handleMove(playerId, data) {
    const player = players.get(playerId);

    if (player) {
        player.x = data.x;
        player.y = data.y;
    }
}

function broadcastGameState() {
    const gameState = {
        type: 'game_state',
        players: serializePlayers()
    };

    const message = JSON.stringify(gameState);

    players.forEach((player) => {
        if (player.ws.readyState === WebSocket.OPEN) {
            player.ws.send(message);
        }
    });
}

function broadcast(message, excludeId = null) {
    const messageStr = JSON.stringify(message);

    players.forEach((player, id) => {
        if (id !== excludeId && player.ws.readyState === WebSocket.OPEN) {
            player.ws.send(messageStr);
        }
    });
}

function serializePlayers() {
    const result = {};

    players.forEach((player, id) => {
        result[id] = {
            x: player.x,
            y: player.y,
            color: player.color
        };
    });

    return result;
}

function generateId() {
    return 'player_' + Math.random().toString(36).substr(2, 9);
}

function randomColor() {
    return '#' + Math.floor(Math.random() * 16777215).toString(16);
}

console.log('遊戲伺服器啟動於 ws://localhost:9000');
<!-- game-client.html -->
<!DOCTYPE html>
<html>
<head>
    <title>多人遊戲</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            background-color: #f0f0f0;
        }

        canvas {
            display: block;
            border: 2px solid #333;
        }

        #info {
            position: absolute;
            top: 10px;
            left: 10px;
            font-family: Arial, sans-serif;
            background-color: rgba(255, 255, 255, 0.8);
            padding: 10px;
            border-radius: 5px;
        }
    </style>
</head>
<body>
    <div id="info">
        玩家數:<span id="player-count">0</span><br>
        使用方向鍵移動
    </div>

    <canvas id="game" width="800" height="600"></canvas>

    <script>
        const canvas = document.getElementById('game');
        const ctx = canvas.getContext('2d');
        const playerCountEl = document.getElementById('player-count');

        const ws = new WebSocket('ws://localhost:9000');

        let myPlayerId = null;
        let players = {};

        // 本地玩家位置(客戶端預測)
        let myPosition = { x: 0, y: 0 };
        const speed = 5;

        // 鍵盤狀態
        const keys = {};

        ws.onmessage = (event) => {
            const data = JSON.parse(event.data);

            switch (data.type) {
                case 'init':
                    myPlayerId = data.playerId;
                    players = data.players;
                    myPosition = players[myPlayerId];
                    break;

                case 'player_joined':
                    players[data.playerId] = data.player;
                    break;

                case 'player_left':
                    delete players[data.playerId];
                    break;

                case 'game_state':
                    // 更新其他玩家位置
                    Object.keys(data.players).forEach((id) => {
                        if (id !== myPlayerId) {
                            players[id] = data.players[id];
                        }
                    });
                    break;
            }

            playerCountEl.textContent = Object.keys(players).length;
        };

        // 鍵盤事件
        document.addEventListener('keydown', (e) => {
            keys[e.key] = true;
        });

        document.addEventListener('keyup', (e) => {
            keys[e.key] = false;
        });

        // 遊戲循環
        function gameLoop() {
            // 處理移動(客戶端預測)
            let moved = false;

            if (keys['ArrowUp'] && myPosition.y > 0) {
                myPosition.y -= speed;
                moved = true;
            }
            if (keys['ArrowDown'] && myPosition.y < canvas.height) {
                myPosition.y += speed;
                moved = true;
            }
            if (keys['ArrowLeft'] && myPosition.x > 0) {
                myPosition.x -= speed;
                moved = true;
            }
            if (keys['ArrowRight'] && myPosition.x < canvas.width) {
                myPosition.x += speed;
                moved = true;
            }

            // 發送位置更新
            if (moved) {
                ws.send(JSON.stringify({
                    type: 'move',
                    x: myPosition.x,
                    y: myPosition.y
                }));

                // 更新本地狀態
                if (players[myPlayerId]) {
                    players[myPlayerId].x = myPosition.x;
                    players[myPlayerId].y = myPosition.y;
                }
            }

            // 渲染
            render();

            requestAnimationFrame(gameLoop);
        }

        function render() {
            // 清空畫布
            ctx.fillStyle = '#f0f0f0';
            ctx.fillRect(0, 0, canvas.width, canvas.height);

            // 繪製所有玩家
            Object.keys(players).forEach((id) => {
                const player = players[id];

                ctx.fillStyle = player.color;
                ctx.beginPath();
                ctx.arc(player.x, player.y, 20, 0, Math.PI * 2);
                ctx.fill();

                // 標示自己
                if (id === myPlayerId) {
                    ctx.strokeStyle = '#000';
                    ctx.lineWidth = 3;
                    ctx.stroke();
                }

                // 顯示 ID(簡化)
                ctx.fillStyle = '#000';
                ctx.font = '12px Arial';
                ctx.textAlign = 'center';
                ctx.fillText(id.substr(0, 8), player.x, player.y - 30);
            });
        }

        // 啟動遊戲循環
        requestAnimationFrame(gameLoop);
    </script>
</body>
</html>

📝 總結

我們實作了四個完整的 WebSocket 應用:

  1. 即時聊天室 💬

    • 多使用者即時通訊
    • 線上狀態、正在輸入
    • 私訊功能
  2. 即時通知系統 🔔

    • 訂閱/取消訂閱
    • 分類通知
    • 視覺化通知彈窗
  3. 協作編輯器 ✏️

    • 多人同步編輯
    • 即時更新
    • 游標位置保持
  4. 多人遊戲 🎮

    • 即時位置同步
    • 客戶端預測
    • 60 FPS 更新

核心技術:

  • WebSocket 雙向通訊
  • 廣播/房間機制
  • 客戶端預測
  • 斷線重連
  • 狀態同步

這些範例展示了 WebSocket 在實際應用中的強大能力!


🔗 延伸閱讀

0%