用 Socket.io 搭配 Matterjs 製作一款 Real-Time Canvas 聊天室(文長慎入)

hi 大家好,我是 Johnny,由於工作的關係,這幾個月下班都只想躺平耍廢,導致好久沒有寫技術分享的文章了,這一次想來分享最近突發奇想製作的一款迷你 Real-Time Canvas 聊天室,透過 Socket.io 結合 Matterjs 看看能碰撞出什麼新火花~

前言

這次使用的技術包含之前製作文字聊天室open in new window(username: public, password: 0000)時使用的 Socket.io,還有製作馬力歐遊戲open in new window時的 MatterJS,本次目標是製作出一款能讓用戶加入聊天室,並建立 2D 人物角色在聊天室中能夠自由走動,並最終在角色頭上顯示文字的小遊戲

警告!本篇涉及稍微偏應用層面的 MatterJS 使用,若對於 MatterJS 不是很有興趣建議跳過這篇XD

成果

為避免文長內容過於單調,先把成果放在這邊,不想看一堆程式碼的可以直接點我看成果open in new window,由於本人經濟拮据,租不起效能比較好的 server 來跑,畫面可能會稍微卡頓還請各位看官們見諒 XD...

Backend Part.

首先我們從後端的 Socket server 開始著手,事先規劃好我們的 socket events 流程,有助於後續開發前端時的串接

建立 server

安裝 express, socket.io,建立一個 server

// src/index.js
const express = require('express');
const { Server } = require('socket.io');
const path = require('path');
const { isProd } = require('./config');

const app = express();
const server = require('http').createServer(app);

const io = new Server(server, {
  cors: {
    origin: '*',
  },
});

// socket events 註冊在這裡
require('./controllers/socket').config(io);

server.listen(port, () => console.log(`NodeJS server on port: ${port}`));

註冊 socket events

由於邏輯都跟程式碼嚴重綁定,直接在 source code 裡添加說明~

// src/controllers/socket.js
// 假的 user
const getAnonymousUser = (socket) => ({
  id: '',
  username: `Anonymous-${socket.id}`,
});

// 偷懶,隨便先暫存 player 資料在記憶體裡,乖小孩不要學喔
const cachedPlayers = new Map();

const config = (io) => {
  // socket 連線
  io.on('connection', async (socket) => {
    // 取得指定 room 裡的所有連線
    const getRoomSet = async (room) => socket.in(room).allSockets();
    // 初始化 user data
    const initUser = async (data) => {
      socket.user = getAnonymousUser(socket);
      socket.room = data.room;
    };

    console.log('a user connected');

    // 用戶離開、斷線時
    socket.on('disconnect', async () => {
      if (!!socket.room) {
        const room = socket.room;
        // leave room
        socket.leave(room);
        // delete cached data in room
        delete cachedPlayers.get(room)[socket.user.id];
        const { size } = (await getRoomSet(room));
        // broadcast someone had leave
        socket.to(room).emit('get-leave', {
          size,
          id: socket.user.id,
        });
      }
    });

    // 用戶第一次進入畫面連線成功後,加入對應房間
    // 1. 通知房內所有人,人數改變
    // 2. 通知新加入的用戶,server 準備完成,可以開始進一步初始化本地的 player data
    socket.on('join', async (data) => {
      if (data.room) {
        if (!cachedPlayers.get(data.room)) {
          cachedPlayers.set(data.room, {});
        }

        socket.join(data.room);
        const { size } = (await getRoomSet(data.room));
        // init and cache user data for socket
        initUser(data);
        // broadcast room size to everyone in same room
        io.to(data.room).emit('new-join', { size });
        // send join ok message to new joined user in order to init it's App
        socket.emit('join-ok', { roomPlayers: cachedPlayers.get(data.room) });
      }
    });

    // 用戶本地 canvas 初始化後,會將 player data 送上來,並透過 socket 傳給房間裡的所有人
    socket.on('push-player', async (data) => {
      console.log('push-player', data);
      if (cachedPlayers.get(data.room)) {
        cachedPlayers.get(socket.room)[data.id] = data;
        socket.user.id = data.id;
      }
      socket.to(socket.room).emit('get-player', data);
    });

    // 用戶更新人物位置時,將相關資料送上來並發送給房內所有人
    socket.on('update-player', async (data) => {
      console.log('update-player', data);
      if (cachedPlayers.get(socket.room)) {
        cachedPlayers.get(socket.room)[data.player.id] = data.player;
        socket.user.id = data.player.id;
      }
      socket.to(socket.room).emit('get-update-player', data);
    });

    // 用戶發出訊息給房內所有人
    socket.on('push-message', async (data) => {
      console.log('push-message', data);
      socket.to(socket.room).emit('get-message', data);
    });
  });
};

module.exports = { config };

到此就完成後端 socket events 的相關設定了!其實說多不多,說少也不少,接下來就開始處理最麻煩的前端部分吧

Frontend Part.

由於前端的部分真的有點複雜,我這邊只把部分源碼放上來說明

useSocket 濃縮精華

import { useRef, useState } from 'react';
import { io } from 'socket.io-client';
import { Body } from 'matter-js';
import Player from '@/lib/matter-js/player';
import setPlayerAnimation from '@/lib/players/setPlayerAnimation';
import useCreation from './useCreation';
import { socketEndpoint } from '../config';
import { items } from '../lib/matter-js/playground';

// Player 物件的結構
export interface PlayerItem {
  id: string;
  username: string;
  imageKey: string;
  pos: {
    x: number;
    y: number;
  };
  size: {
    w: number;
    h: number;
  };
  status: string;
  moving: boolean;
}

// 建立 player 物件
export function createPlayerItem(player: any): PlayerItem {
  return {
    id: player.id,
    username: player.username,
    imageKey: player.imageKey,
    size: player.size,
    pos: player.body.position,
    status: player.status,
    moving: player.moving,
  }
}

// 本體!
function useSocket() {
  // 透過 useCreation 讓 socket 連線始終保持唯一
  const socket = useCreation(() => io(socketEndpoint), []);
  // 保存連線狀態
  const [isConnected, setIsConnected] = useState(socket.connected);
  // 房間人數
  const [roomSize, setRoomSize] = useState(0);
  // 房內其他 player 的物件列表(純傳遞的資料)
  const [roomPlayers, setRoomPlayers] = useState<Record<string, PlayerItem>>();
  // 用於 MatterJS 渲染的真實 render 物件(真實渲染物件)不包含當前 player 本人
  const renderPlayersRef = useRef<Player[]>([]); // not include self
  const [delay, setDelay] = useState(0);
  // 工具狀態
  const querys = new URLSearchParams(window.location.search);
  const roomId = querys.get('room') ?? 'default';

  // 初始化 roomPlayers,必須在 loader(載入圖片)後呼叫
  const initRoomPlayers = () => {
    // 本地第一次初始化(剛加入房間,還沒渲染其他用戶資料)
    if (roomPlayers && renderPlayersRef.current.length < 1) {
      Object.values(roomPlayers).forEach((player) => {
        // 檢查是否為用戶本人 id,避免渲染自己第二次
        if (player.id !== items.player.id) {
          onGetPlayer(player);
        }
      });
    }
  };
  // 初次連線上 socket,呼叫 join,初始化 server 端用戶資料
  const onConnect = () => {
    console.log(socket);
    setIsConnected(true);
    socket.emit('join', {
      room: roomId,
    });
  };
  // 斷線
  const onDisconnect = () => {
    setIsConnected(false);
  };
  // 由外部傳入 initApp 的操作,回傳為 socket event "join-ok"
  // 會在 server 初始化完成當前用戶資料時被呼叫
  // 此步驟 server 會把目前 room 裡面的所有用戶資料傳給新加入的此用戶
  const initJoinOk = (initApp: any) => ({ roomPlayers: rp }: { roomPlayers: Record<string, PlayerItem> }) => {
    console.log('room cache', rp);
    setRoomPlayers(rp);
    initApp();
  };
  // 當有其他新的用戶加入觸發 join 事件後,更新本地顯示的房內人數
  const onNewJoin = ({ size }: { size: number }) => {
    setRoomSize(size);
  };
  // 當有用戶離開房間時,更新房間人數,並將該用戶對應的 render 資料銷毀(從畫面上移除人物)
  const onGetLeave = ({ size, id }: { size: number, id: string }) => {
    setRoomSize(size);
    const renderPlayers = renderPlayersRef.current;
    const targetIndex = renderPlayers.findIndex((player) => player.id == id);
    const [targetPlayer] = renderPlayers.splice(targetIndex, 1);
    if (targetPlayer) targetPlayer.destroy();
    renderPlayersRef.current = renderPlayers;
  };
  // 當有新用戶加入房間成功,並推送其 player 資料到房間
  // 初始化並加入新的 render player 物件(把人物加入到 canvas 中)
  const onGetPlayer = (newPlayer: PlayerItem) => {
    console.log('new player', newPlayer);
    const player = new Player(
      newPlayer.username,
      newPlayer.pos.x,
      newPlayer.pos.y,
      newPlayer.size.w,
      newPlayer.size.h,
    );
    player.id = newPlayer.id;
    player.status = newPlayer.status;
    player.moving = newPlayer.moving;
    setPlayerAnimation(player, newPlayer.imageKey);

    player.mount().render();
    // save player
    renderPlayersRef.current = [...renderPlayersRef.current, player];
  };
  // 當其他用戶更新他們的 player 資料時,會同時帶著該用戶操作的時間戳記
  // 1. 若用戶切換 moving 狀態為 true,紀錄該用戶實際開始操作的時間距離現在的時間差為 delayTime
  // 2. 若用戶切換 moving 狀態為 false,紀錄用戶實際停止操作的時間距離現在的時間差,並將剛剛紀錄的時間相減,得到真實 delay 時間,避免人物移動距離偏差
  const onGetUpdatePlayer = ({ player: updatePlayer, time }: { player: PlayerItem, time: number }) => {
    console.log('update-player');
    const renderPlayers = renderPlayersRef.current;
    const targetIndex = renderPlayers.findIndex((player) => {
      return player.id === updatePlayer.id;
    });
    const targetPlayer = renderPlayers[targetIndex];
    if (targetPlayer) {
      // other player start moving
      if (updatePlayer.moving == true) {
        // start delay time(+delay)
        setDelay(Date.now() - time);
        targetPlayer.status = updatePlayer.status;
        targetPlayer.moving = updatePlayer.moving;
      } else {
        // end delay time(-delay)
        const realDelay = delay - (Date.now() - time);
        setTimeout(() => {
          targetPlayer.status = updatePlayer.status;
          targetPlayer.moving = updatePlayer.moving;
          Body.setPosition(targetPlayer.body, updatePlayer.pos);
        }, realDelay);
      }
    }
    renderPlayersRef.current = renderPlayers;
  };
  // 當接收到用戶發送的 message
  const onGetMessage = ({ id, message }: { id: string, message: string }) => {
    const [targetPlayer] = renderPlayersRef.current.filter((player) => {
      return player.id === id;
    });
    if (targetPlayer) {
      targetPlayer.say(message);
    }
  };

  return {
    socket,
    isConnected,
    roomSize,
    roomPlayers,
    renderPlayersRef,
    delay,
    initRoomPlayers,
    onConnect,
    onDisconnect,
    onNewJoin,
    initJoinOk,
    onGetLeave,
    onGetPlayer,
    onGetUpdatePlayer,
    onGetMessage,
  };
}

export default useSocket;

Player 物件

import { Events, Body, Composite } from 'matter-js';
import { randomId } from '@/lib/random';
import Box from './box';
import engine from './engine';
import { render } from './render';
import CustomRender from './customRender';
import { ctx, canvasConfig } from './config';
import drawTextBG, { drawText } from '../drawTextBg';

class Player extends Box {
  constructor(username, x, y, w, h) {
    super(x, y, w, h, {
      // 設定人物在 Matterjs 中碰撞的 group
      collisionFilter: {
        category: canvasConfig.categories.player,
        mask: canvasConfig.categories.default,
      },
      render: {
        fillStyle: 'transparent',
      }
    });
    this.id = randomId();
    this.username = username;
    this.status = 'moveDown'; // 人物狀態,透過 render 方法會根據當前 status 挑選對應的狀態渲染
    this.moving = false;
    this.animation = {};
    // --
    this.messageTimer = null;
    this.message = '';
    // --
    this.onKeyDown = () => {};
    this.onKeyUp = () => {};
  }

  listenKeyDown(handlerDown, handlerUp) {
    window.addEventListener('keydown', handlerDown);
    window.addEventListener('keyup', handlerUp);
    return () => {
      window.removeEventListener('keydown', handlerDown);
      window.removeEventListener('keyup', handlerUp);
    }
  }

  listen() {
    const self = this;
    const handlerDown = (event) => {
      const { body } = self;
      if (self.moving) return;
      if (event.keyCode > 36 && event.keyCode < 41) {
        self.moving = true;
      }
      switch (event.keyCode) {
        case 37:
          self.status = 'moveLeft';
          break;
        case 38:
          self.status = 'moveUp';
          break;
        case 39:
          self.status = 'moveRight';
          break;
        case 40:
          self.status = 'moveDown';
          break;
      }
      self.onKeyDown(event); // 外部傳入
    };
    const handlerUp = (event) => {
      const { animation } = self;
      self.moving = false;
      switch (event.keyCode) {
        case 37:
          animation.moveLeft.reset();
          break;
        case 38:
          animation.moveUp.reset();
          break;
        case 39:
          animation.moveRight.reset();
          break;
        case 40:
          animation.moveDown.reset();
          break;
      }
      self.onKeyUp(event); // 外部傳入
    };
    return this.listenKeyDown(handlerDown, handlerUp);
  }

  moveLeft(allow) {
    const { body, animation, moving } = this;
    animation.moveLeft.loop();
    if (!moving || !allow) return;
    Body.translate(body, { x: -3, y: 0 });
    animation.moveLeft.play();
  }

  moveUp(allow) {
    const { body, animation, moving } = this;
    animation.moveUp.loop();
    if (!moving || !allow) return;
    Body.translate(body, { x: 0, y: -3 });
    animation.moveUp.play();
  }

  moveRight(allow) {
    const { body, animation, moving } = this;
    animation.moveRight.loop();
    if (!moving || !allow) return;
    Body.translate(body, { x: 3, y: 0 });
    animation.moveRight.play();
  }

  moveDown(allow) {
    const { body, animation, moving } = this;
    animation.moveDown.loop();
    if (!moving || !allow) return;
    Body.translate(body, { x: 0, y: 3 });
    animation.moveDown.play();
  }

  drawUsername() {
    const { body: { position: pos }, size, username } = this;
    const textWidth = ctx.measureText(username).width;
    drawText(ctx, username.slice(0, 12), '14px arial', pos.x - textWidth/2, pos.y + size.h/2, 'black');
  }

  saying() {
    const { body: { position: pos }, size, message } = this;
    const textWidth = ctx.measureText(message).width;
    drawTextBG(ctx, message, '16px arial', pos.x - textWidth/2 - 6, pos.y - size.h, 12);
  }

  say(newMessage) {
    if (!!newMessage) {
      clearTimeout(this.messageTimer);
      this.message = newMessage;
      this.messageTimer = setTimeout(() => {
        this.message = '';
      }, 6000);
    }
  }

  destroy() {
    console.log('destroy');
    Composite.remove(engine.world, this.body);
    this.customRender.draw = () => {};
    Events.off(render, 'afterRender', this.customRender.step);
  }

  render() {
    const box = this;
    // use custom render to fix frame rate
    // in order to let movement matched in each devices
    const customRender = new CustomRender();
    box.customRender = customRender
    customRender.draw = (allow) => {
      // username
      box.drawUsername();
      // status
      if (typeof box[box.status] === 'function') {
        box[box.status](allow);
      }
      // saying
      if (!!box.message) {
        box.saying();
      }
    };
    Events.on(render, 'afterRender', customRender.step);
    return box;
  }
}

export default Player;

初始化 playground

這段主要是初始化整個 MatterJS 的初次渲染畫面及相關物件

import { Engine, Render, Runner, Composite } from 'matter-js';
import { createPlayerItem } from '@/hooks/useSocket';
import engine from './engine';
import runner from './runner';
import { keepWatch, render } from './render';
import Player from './player';
import Joystick from './joystick';
import Box from './box';
import { canvasConfig } from './config';
import setPlayerAnimation from '../players/setPlayerAnimation';

export const items: Record<string, any> = {};

const boundOptions = () => ({
  render: {
    fillStyle: 'brown',
  }
})

// 初始化邊界
function initEdgeBounds() {
  // top
  new Box(
    canvasConfig.width / 2,
    -20,
    canvasConfig.width,
    50,
    boundOptions(),
  ).setStatic().mount();
  // bottom
  new Box(
    canvasConfig.width / 2,
    canvasConfig.height + 20,
    canvasConfig.width,
    50,
    boundOptions(),
  ).setStatic().mount();
  // left
  new Box(
    -20,
    canvasConfig.height / 2,
    50,
    canvasConfig.height,
    boundOptions(),
  ).setStatic().mount();
  // right
  new Box(
    canvasConfig.width + 20,
    canvasConfig.height / 2,
    50,
    canvasConfig.height,
    boundOptions(),
  ).setStatic().mount();
}

// 初始化本地用戶
export function initPlayer(socket: any, username: string, imageKey: string) {
  const player = new Player(
    username,
    canvasConfig.width / 2,
    canvasConfig.height / 2,
    48,
    64,
  );
  // set animation
  const querys = new URLSearchParams(window.location.search);
  const playerImageKey = Math.ceil(Math.random() * canvasConfig.playerCount);
  setPlayerAnimation(player, imageKey ?? `player${playerImageKey}`);
  // mount & render
  player.mount().render();
  items.player = player;
  // watch player
  keepWatch(player);
  // set socket events
  const onKeyDown = () => {
    socket.emit('update-player', {
      player: createPlayerItem(player),
      time: Date.now(),
    });
  };
  const onKeyUp = () => {
    socket.emit('update-player', {
      player: createPlayerItem(player),
      time: Date.now()
    });
  };
  player.onKeyDown = onKeyDown;
  player.onKeyUp = onKeyUp;
  // 把本地 player data 發送給其他房內的人
  socket.emit('push-player', createPlayerItem(player));
  // joystick 手機版觸控控制器
  const joystick = new Joystick(160, canvasConfig.height - 160, {
    size: 80
  });
  joystick.detectPress({
    onKeyDown,
    onKeyUp,
    up: () => {
      player.status = 'moveUp';
      player.moving = true;
    },
    down: () => {
      player.status = 'moveDown';
      player.moving = true;
    },
    left: () => {
      player.status = 'moveLeft';
      player.moving = true;
    },
    right: () => {
      player.status = 'moveRight';
      player.moving = true;
    },
    reset: () => {
      player.moving = false;
    },
  })
  joystick.render();

  const clearUserListen = player.listen();
  return clearUserListen;
}

function initPlayground() {
  // 初始化邊界
  initEdgeBounds();
  return {
    items,
    clear() {
      Composite.clear(engine.world, false);
      Engine.clear(engine);
      Render.stop(render);
      Runner.stop(runner);
      render.canvas.remove();
      render.textures = {};
    },
  };
}

export default initPlayground;

React Page

終於到真正的 page 主體了...

import { Runner, Render } from 'matter-js';
import { useRef, useState, useEffect } from 'react';
import { Input, Button, Modal } from 'antd';
import clsx from 'clsx';
import useSocket from '@/hooks/useSocket';
import useKeyDown from '@/hooks/useKeyDown';
import engine from '@/lib/matter-js/engine';
import runner from '@/lib/matter-js/runner';
import createRender from '@/lib/matter-js/render';
import initPlayground, { items, initPlayer } from '@/lib/matter-js/playground';
import Loader from '@/lib/loader';
import initConfig, { canvasConfig } from '@/lib/matter-js/config';

// 初始化圖片載入的 loader
const loader = new Loader();
Array(canvasConfig.playerCount).fill('').forEach((_, index) => {
  loader.add(`player${index + 1}`, `/game/player${index + 1}.png`);
});

// 頁面本體!!
function Home() {
  // socket config
  const { socket, isConnected, roomSize, roomPlayers, delay, initRoomPlayers, onConnect, onDisconnect, initJoinOk, onNewJoin, onGetLeave, onGetPlayer, onGetUpdatePlayer, onGetMessage } = useSocket();
  // util
  const [isInitialized, setIsInitialized] = useState(false);
  const [openModal, setOpenModal] = useState(true);
  // states
  const renderRef = useRef<Render>();
  const containerRef = useRef(null);
  const [username, setUsername] = useState('');
  const [imageKey, setImageKey] = useState('');
  const [message, setMessage] = useState('');
  const [Items, setItems] = useState<any>(); // for local objects

  // 送出 message
  const handleSendMessage = () => {
    if (message) {
      socket.emit('push-message', {
        id: Items.player.id,
        message,
      });
      // 在人物頭上放文字~
      Items.player.say(message);
      setMessage('');
    }
  };
  // 初始化 Matter render
  const initializeMatter = async () => {
    if (!containerRef.current) return;
    const render = renderRef.current = createRender(containerRef.current);
    render.canvas.width = canvasConfig.width;
    render.canvas.height = canvasConfig.height;
    initConfig({
      canvas: render.canvas,
      loader,
    });

    Runner.run(runner, engine);
    Render.run(render);

    const { items, clear: clearPlayground } = initPlayground();
    setItems(items);
    return () => {
      clearPlayground();
    };
  };
  // 初始化 App,這個 function 必須在 Socket join-ok 後才能調用,所以會傳進去 `initJoinOK` 裡面
  const initApp = async () => {
    await loader.load();
    await initializeMatter();
  };

  // 不重要,按下 enter 自動送出 message 而已
  useKeyDown((e: React.KeyboardEvent) => {
    if (e.keyCode === 13) {
      handleSendMessage();
    }
  });

  // 等待 socket 拿到 roomPlayer,並且 Matter 初始化完畢才能把其他用戶渲染到畫布上!
  useEffect(() => {
    if (!!roomPlayers && isInitialized) {
      initRoomPlayers();
    }
  }, [isInitialized, roomPlayers]);

  // 初始化 socket events
  useEffect(() => {
    // initApp executed after socket "join ok"(init server user information)
    const onJoinOk = initJoinOk(initApp);
    socket.on('connect', onConnect);
    socket.on('disconnect', onDisconnect);
    socket.on('new-join', onNewJoin);
    socket.on('join-ok', onJoinOk);
    socket.on('get-leave', onGetLeave);
    socket.on('get-player', onGetPlayer);
    socket.on('get-update-player', onGetUpdatePlayer);
    socket.on('get-message', onGetMessage);
    return () => {
      socket.off('connect', onConnect);
      socket.off('disconnect', onDisconnect);
      socket.off('new-join', onNewJoin);
      socket.on('join-ok', onJoinOk);
      socket.off('get-leave', onGetLeave);
      socket.off('get-player', onGetPlayer);
      socket.off('get-update-player', onGetUpdatePlayer);
      socket.off('get-message', onGetMessage);
    };
  }, [delay]);

  // 入口彈窗,給用戶選角色、填寫名子
  return (
    <div className="max-w-3xl mx-auto">
      <Modal
        open={openModal}
        onOk={() => {
          initPlayer(socket, username, imageKey);
          setItems(items);
          setOpenModal(false);
          setIsInitialized(true);
        }}
        okButtonProps={{
          disabled: !username || !imageKey
        }}
      >
        <div>
          What's your name?
          <Input value={username} onChange={(e) => setUsername(e.target.value)} />
        </div>
        <div className="mt-4">
          Choose Character below:
          <div className="h-[250px] grid grid-cols-4 gap-4 mt-2 overflow-auto">
            {
              Array(canvasConfig.playerCount).fill('').map((_, index) => (
                <div
                  key={`image${index + 1}`}
                  className={clsx(['w-16 h-16 overflow-hidden rounded', {
                    'bg-blue-500': `player${index + 1}` === imageKey,
                  }])}
                  onClick={() => setImageKey(`player${index + 1}`)}
                >
                  <img src={`/game/player${index + 1}.png`} />
                </div>
              ))
            }
          </div>
        </div>
      </Modal>

      online: {roomSize}
      <p className="text-sm text-red-500">
        *如果沒有看到其他聊天室的人出現在畫面中,請嘗試重新進入頁面一次
      </p>
      {isConnected
        ? <div ref={containerRef} className="[&>canvas]:w-full"></div>
        : <div>socket disconnected, please reload page</div>
      }
      <div className="flex my-2">
        <Input
          value={message}
          placeholder="Press Enter to send message"
          onChange={(e) => setMessage(e.target.value)}
        />
        <Button onClick={handleSendMessage} className="ml-2">Send</Button>
      </div>
    </div>
  )
}

export default Home;

到此我們的主體就完成拉!!抱歉我省略了很多部分,因為內容實在太多了...,但大致上的流程跟細節就只有這樣而已

結語

這次心血來潮開發這個 Side Project,主要是想透過練習,把一些不這麼常在工作上用到的技術做一次稍微深度的整合應用,單獨開發 Socket 沒問題,單獨開發 MatterJS 沒問題,那把兩個加在一起搞看還能不能一樣淡定(本人這次一點都不淡定QQ)

這次在開發過程中好幾次不淡定,舉凡在 MatterJS, SocketJS 的初始化順序、時機上撞牆了好幾次,也在這次練習經驗中學到很多細節,如果時間允許而你也有興趣的話,誠心推薦也動手實做看看~

那這次分享就到這邊拉,下次分享不知道是何時,但總會再出現 =V=,下篇文章見拉大家!

Last Updated:
Contributors: johnnywang