Johnny Wang BlogJohnny Wang Blog
Articles
Project
Live2d
johnnywang/book
Articles
Project
Live2d
johnnywang/book
  • About Me

    • 關於 Johnny
    • 文章清單
  • Javascript

    • View Transitions - 認識並使用原生 document 轉場效果
    • 用 Web Container 打造自己的線上 NodeJS 開發環境
    • 來試用看看原生 Web Popover API
    • 在電腦裡搞一個 RWKV AI 小助手
    • 遊戲 App 素材解包學習紀錄
    • 動手自己做一個 ChatGPT UI 工具吧
    • Create a React Server Components Project without NextJS - 製作一個不依賴 NextJS 的 React Server Components 專案
    • 如何在 Vuepress 裡快樂寫 React
    • 從 Mock Service Worker 源碼中學習
    • 擺脫 Node modules 地獄,擁抱 Yarn Plug'n'Play(PnP)
    • 快速上手 NextJS v13 - 基礎觀念 AppRouter 篇
    • 快速上手 NextJS v13 - Data Fetching, Caching, Revalidating 篇
    • 用 Socket.io 搭配 Matterjs 製作一款 Real-Time Canvas 聊天室(文長慎入)
      • 前言
      • 成果
      • Backend Part.
      • Frontend Part.
      • 結語
    • 如何只用一支 CDN 及 4行設定,讓瀏覽器讀懂 Typescript, React, Vue
    • 用 tsup 快速建立 Typescript 開發環境
    • 開發 Email EDM 你可以更輕鬆
    • 關於我的 Side project - Maju Web Editor
    • Web URL 中神奇的 createObjectURL method
    • Awesome Vite 一個由社群維護的 template 集大成
    • Vue、React 使用心得分享(文長慎入)
    • 一起動手用 Socket.io 和 Peerjs 打造 WebRTC 即時視訊
    • Temporal Typescript SDK 學習筆記
    • React Web3 Storage
    • React useState 取得最新值
    • Babel 7 Decorator 的神奇小問題
    • 用 Koa + Vite + Pinia 打造基礎 SSR 環境
    • Live2d 官方範例改寫
    • 如何把 Video 畫在 Canvas 上
    • 史上最簡單的 Webpack 5 教學
    • 在瀏覽器中直接 import Vue SFC 開發起來
    • 手寫一個可中斷的 delay promise
    • NodeJS 輕量開發框架 Expressjs 與 Koa2 的區別
    • 用 2D 物理引擎 Matterjs 製作經典馬力歐 1-1
    • 原生 Javascript 的類型標註工具 JSDoc
    • Pinia - Vuex 的後繼者
    • 用 Nodejs 寫個 FTP command line 工具
    • Vue3 Server Render 手把手帶你搭建
    • 你真的懂 Event Loop 嗎
    • Babel7 基本介紹與使用
    • Vue/Vitejs 部分源碼解析
    • Vuejs 依賴追蹤 2020 版
    • Vuejs 依賴追蹤 2019 版
    • Js literal 模板編譯
    • 用 JavaScript 鎖定用戶調整畫面比例
    • Date.now() 與 new Date()
    • Promise 相關知識
    • Web Component 學習筆記
    • TypeScript 基礎篇
    • TypeScript 進階篇
    • MVC, MVVM, MVI 軟體設計架構
    • MVVM 簡單模擬框架實作
  • CSS & Sass

    • 2024 CSS 年度報告筆記
    • 2021 CSS 年度報告筆記
    • 如何不用 setTimeout 幫 display: none 的 DOM 加動畫
    • 純 CSS 實作星球環繞動畫效果
    • 差點錯過的 Tailwindcss 入門學習筆記
    • Tailwindcss 進階學習筆記
    • Sass, SCSS Built-In-Modules 內建模組與函數
    • SCSS Parent Selector
    • CSS 畫面鎖橫屏時,滾動的問題!!
    • Safari 使用 animation 時動態產生 rem 的坑
    • CSS: mix-blend-mode 屬性混合圖層動畫
  • Memo

    • Javascript

      • Yarn Plug'n'Play (PnP) 的 VSCode 設定方式
      • 如何用 Web API 讀取剪貼簿內容
      • CSP (Content Security Policy) 是什麼?
      • Astro 入門學習筆記
      • 如何解決 Astro 套用主題切換時,畫面抖動瞬閃問題
      • Rspack - 以 Rust 打造的快速構建工具
      • Typescript v5 Decorator 學習筆記
      • Sequelize 筆記
      • ES2022 學習筆記
      • Crypto 密碼加密方法
      • 實作 Test 的注意事項
      • Jest 測試工具 - 基礎篇
      • 正則表達式
      • Rxjs 學習筆記
      • Gulp 學習筆記
      • Clean Code Javascript 學習
      • JS 實戰開發特殊小技巧
    • React

      • React forwardRef 使用筆記
      • 如何在 Nextjs 中使用 middleware set cookie
      • React Styled-Components 基礎篇
      • React Styled-Components 進階篇
      • React Unit Test
      • React Testing Library
      • React Emotion Basic
      • React Styled-JSX
      • React 18 - Concurrent Features Memo
      • useEffect 的高階封裝範例
      • React useContext & useReducer 搭配
      • React Router Config 筆記
    • Vue

      • Cypress Vue
      • Vue 單元測試學習筆記
      • 學習 Vuetify 的一些筆記
      • Vuex 學習筆記
    • React Native

      • React Native - basic
      • Expo Basic
      • Expo Router
      • Expo Build
      • Issues
    • GraphQL

      • GraphQL 學習筆記 - 基礎篇
      • GraphQL 學習筆記 - 進階篇
      • Apollo Client 學習使用筆記
      • GraphQL Memo
    • Parse

      • Parse Javascript 文檔閱讀筆記
      • Parse User Object 章節
      • Parse Session Object 章節
      • Parse Schema Object 章節
      • Parse Cloud Code 章節
    • CSS

      • Sass, SCSS 基礎筆記
    • Docker

      • Docker 基礎技術
      • Dockerfile 技術篇
      • Docker-compose 技術篇 - 3.7
      • Kubernetes 學習筆記
      • ArgoCD 學習筆記
      • Podman 學習筆記
      • Colima 安裝使用筆記
    • Git Learning

      • Git 版本控制
      • Git 基礎使用 - init, clone, add, commit, status, log
      • Git 分支
      • Git Merge 分支合併指令
      • Git Stash 保存紀錄
      • Git 基礎復原指令 - restore
      • Remote 遠端協同工作 - remote, fetch, pull, push
      • Git Tag 標籤 - tag
      • Git 設定 - config
      • Git 檔案比對與差異 - diff
      • Git Rebase 定義分支的參考基準
      • Subtree 子樹
      • Git Reflog 指令紀錄
      • Git Filter Branch
      • Git Crypt 使用筆記
      • Git 客製化工具
    • Bash

      • Bash 基礎概念
      • Variable 變數
      • Script 腳本撰寫
      • Condition 流程控制
      • Operation 算數表達式
      • Function 函數
      • Command Line Params 指令列參數處理
    • Python

      • Pyenv with virtualenv 配置
    • Patterns

      • Introduce
      • Design Patterns

        • Singleton Pattern
        • Compound Pattern
        • Proxy Pattern
        • Hooks Pattern
        • Observer Pattern
        • Mediator/Middleware Pattern
        • HOC Pattern
        • Factory Pattern
        • Render Props Pattern
        • Flyweight Pattern
        • Container/Presentational Component
        • Prototype Pattern
        • Mixin Pattern
        • Provider Component
        • Command Component
        • Module Pattern
      • Render Patterns

        • Rendering Patterns 渲染模式介紹
    • FE 性能優化

      • 前端性能優化
      • 靜態資源優化
      • 頁面渲染架構優化
      • Server 與 Network 優化
      • 開發流程、監控體系優化
    • Ollama 筆記
    • Nginx JS module 基本使用筆記
    • FlowiseAI 使用介紹筆記
    • Github Copilot 使用筆記
    • Traefik Memo
    • API First 學習筆記
    • What is AC - Acceptance Criteria 驗收條件
    • Mermaid 學習筆記
    • 串接 Sonarcube 筆記
    • Youtube Data API 基礎使用筆記
    • FB API 學習筆記
    • VSCode 好用快捷鍵
    • 在 Simulator 上開發測試
    • 問題修正紀錄
  • References

    • 全端開發學習資源
    • 實用工具
    • 網頁前端課程目錄
  • Daily

    • 中醫學習

      • 中醫自學方向
      • 黃帝內經

        • 黃帝內經-素問
      • 基礎理論

        • 中藥治病
        • 中藥產地與採收
        • 陰陽五行
        • 精氣血津液
        • 整體觀與臟象
        • 病因病機
        • 辨證論治
        • 四診八綱
        • 五運六氣
        • 經絡
      • 診斷學

        • 辯證
        • 望診
        • 聞診
        • 問診
        • 脈診簡史
        • 切診
        • 常見脈象與臨床意義
        • 經脈辨證
        • 中醫的六邪在疾病初期、後期分別有那些症狀
      • 中藥學

        • 中藥基礎理論
        • 常見中藥材
        • 解表藥(30)
        • 清熱藥(82)
        • 瀉下藥(16)
        • 利水渗湿药(8)
        • 化痰止咳平喘药(15)
        • 止血藥(8)
        • 開竅-平肝-安神藥(11)
        • 化濕藥(5)
        • 祛風濕藥(12)
        • 理氣藥(13)
        • 活血祛瘀藥(7)
        • 消食藥(4)
        • 驅蟲藥(6)
        • 祛寒药(6)
        • 補虛藥(43)
        • 收斂藥(13)
      • 方劑學

        • 總論
        • 解表劑
        • 瀉下劑
        • 清熱劑
        • 理血劑
        • 去暑劑
        • 去濕劑
      • 針灸學

        • 特定穴
      • 其他

        • 內證觀察筆記 上篇【內證漫談】
        • 內證觀察筆記 中篇【太極器官、五藏】
        • 內證觀察筆記 下篇【十二正經】
        • 磁力對生理的影響
    • 易經學習

      • 易經入門
    • 道德經學習

      • 道德經學習重點筆記
      • 第一章 道可道,非常道
      • 第二章 夫唯弗居,是以不去
      • 第三章 不尚賢,使民不爭
      • 第四章 道沖而用之或不盈,淵兮似萬物之宗
      • 第五章 天地不仁,以萬物為芻狗,多聞數窮,不如守中
      • 第六章 谷神不死,是謂玄牝
      • 第七章 天長地久。天地所以能長且久者,以其不自生,故能長生。
      • 第八章 上善若水。水善利萬物而不爭,處眾人之所惡,故幾於道
      • 第九章 功成身退,天之道也。
      • 第十章 載營魄抱一,能無離乎﹖
      • 第十一章 有之以為利,無之以為用
      • 第十二章 五色令人目盲,五音令人耳聾,五味令人口爽,馳騁畋獵令人心發狂,難得之貨令人行妨。
      • 第十三章 寵辱若驚,貴大患若身。愛以身為天下,若可托天下
      • 第十四章 是謂無狀之狀,無物之象,是謂惚恍。迎之不見其首,隨之不見其後。執古之道,以御今之有。
      • 第十五章 保此道者,不欲盈。夫唯不盈,故能蔽而新成。
      • 第十六章 致虛極,守靜篤。萬物並作,吾以觀復。
      • 第十七章 太上,下知有之。悠兮其貴言,功成事遂,百姓皆謂我自然。
      • 第十八章 大道廢,有仁義;智慧出,有大偽;
      • 第十九章 絕聖棄智,民利百倍。見素抱樸,少私寡欲
      • 第二十章 絕學無憂。眾人皆有餘,而我獨若遺。
      • 第二十一章 道之為物,惟恍惟惚。惚兮恍兮,其中有象;恍兮惚兮,其中有物。
      • 第二十二章 曲則全,枉則直,窪則盈,敝則新,少則得,多則惑。是以聖人抱一為天下式
      • 第二十三章 希言自然。天地尚不能久,而況於人乎?信不足焉,有不信焉。
      • 第二十四章 企者不立,跨者不行
      • 第二十五章 有物混成,先天地生。人法地,地法天,天法道,道法自然。
      • 第二十六章 重為輕根,靜為躁君。輕則失本,躁則失君。
      • 第二十七章 善行無轍跡,善言無瑕謫。故善人者,不善人之師;不善人者,善人之資。不貴其師,不愛其資,雖智大迷,是謂要妙。
      • 第二十八章 知雄守雌,為天下谿,知榮守辱,為天下谷
      • 第二十九章 天下神器,不可為也,是以聖人去甚,去奢,去泰。
      • 第三十章 以道佐人主者,不以兵強天下
      • 第三十一章 夫兵者,不祥之器,物或惡之,故有道者不處。
      • 第三十二章 道常無名,樸雖小,天下莫能臣也。
      • 第三十三章 知人者智,自知者明。
      • 第三十四章 大道氾兮,其可左右。以其終不自為大,故能成其大。
      • 第三十五章 執大象,天下往。往而不害,安平太。樂與餌,過客止。
      • 第三十六章 將欲歙之,必固張之;將欲弱之,必固強之
      • 第三十七章 道常無為而無不為
      • 第三十八章 上德不德,是以有德;下德不失德,是以無德
      • 第三十九章 昔之得一者,天得一以清。不欲琭琭如玉,珞珞如石。
      • 第四十章 反者道之動,弱者道之用。天下萬物生於有,有生於無
      • 第四十一章 上士聞道,勤而行之;大方無隅,大器晚成,大音希聲,大象無形,道隱無名。
      • 第四十二章 道生一,一生二,二生三,三生萬物。
      • 第四十三章 天下之至柔,馳騁天下之至堅
      • 第四十四章 名與身孰親﹖身與貨孰多﹖得與亡孰病﹖
      • 第四十五章 大成若缺,其用不弊。大盈若沖,其用不窮。
      • 第四十六章 天下有道,卻走馬以糞。天下無道,戎馬生於郊。
      • 第四十七章 不出戶,知天下;不窺牖,見天道。
      • 第四十八章 為學日益,為道日損。損之又損,以至於無為。
      • 第四十九章 聖人無常心,以百姓心為心。
      • 第五十章 出生入死。生之徒,十有三;死之徒,十有三;人之生,動之死地,亦十有三。
      • 第五十一章 道生之,德畜之,物形之,勢成之。
      • 第五十二章 天下有始,以為天下母。以知其子,既知其子,復守其母,沒身不殆。
      • 第五十三章 使我介然有知,行於大道,唯施是畏。大道甚夷,而人好徑。
      • 第五十四章 善建者不拔,善抱者不脫,子孫以祭祀不輟。
      • 第五十五章 含德之厚,比於赤子。知和曰常,知常曰明。益生曰祥。心使氣曰強。
      • 第五十六章 知者不言,言者不知。
      • 第五十七章 以正治國,以奇用兵,以無事取天下。
      • 第五十八章 其政悶悶,其民淳淳;其政察察,其民缺缺。禍兮福之所倚,福兮禍之所伏。
      • 第五十九章 治人事天,莫若嗇。夫唯嗇,是謂早服
      • 第六十章 治大國,若烹小鮮。夫兩不相傷,故德交歸焉。
      • 第六十一章 大國者下流,天下之交。天下之牝,牝常以靜勝牡,以靜為下。
      • 第六十二章 道者萬物之奧。雖有拱璧以先駟馬,不如坐進此道。
      • 第六十三章 為無為,事無事,味無味。圖難於其易,為大於其細;
      • 第六十四章 合抱之木,生於毫末;九層之臺,起於累土;千里之行,始於足下。為者敗之,執者失之。
      • 第六十五章 古之善為道者,非以明民,將以愚之。
      • 第六十六章 是以欲上民,必以言下之。欲先民,必以身後之。
      • 第六十七章 天下皆謂我道大,似不肖。夫唯大,故似不肖。
      • 第六十八章 善為士者不武,善戰者不怒,善勝敵者不與
      • 第六十九章 吾不敢為主而為客,不敢進寸而退尺。禍莫大於輕敵,輕敵幾喪吾寶。
      • 第七十章 吾言甚易知,甚易行。天下莫能知,莫能行。
      • 第七十一章 知不知上,不知知病。夫唯病病,是以不病。
      • 第七十二章 民不畏威,則大威至。無狎其所居,無厭其所生。夫唯不厭,是以不厭。
      • 第七十三章 天之道,不爭而善勝,不言而善應,不召而自來,繟然而善謀。
      • 第七十四章 民不畏死,奈何以死懼之?若使民常畏死,而為奇者,吾得執而殺之,孰敢?
      • 第七十五章 民之饑,以其上食稅之多,是以饑。
      • 第七十六章 人之生也柔弱,其死也堅強。萬物草木之生也柔脆,其死也枯槁。
      • 第七十七章 天之道,其猶張弓與﹖高者抑之,下者舉之;有餘者損之,不足者補之。
      • 第七十八章 天下莫柔弱於水,而攻堅強者莫之能勝,以其無以易之。
      • 第七十九章 和大怨,必有餘怨,安可以為善﹖
      • 第八十章 小國寡民。使有什伯之器而不用,使民重死而不遠徙。
      • 第八十一章 信言不美,美言不信。天之道,利而不害;聖人之道,為而不爭。
    • 人類真相推廣協會

      • 人類真相介紹
      • 人生大挑戰-閱讀筆記
      • 書籍-人生大挑戰

        • 序文
        • 童年的回憶
        • 賺外快的童年
        • 養家的童年
        • 少年時期的回憶
        • 粉紅睡衣女鬼的祕密
        • 麵線、甘蔗和賽鴿
        • 我在黑社會的日子
        • 『台北一條龍』
        • 賭徒‧妻子‧盤仔人
        • 人鬼之戰—正邪不分的恐怖
        • 開展創業石銅雕畫的日子
        • 渡畜牲者‧瞎掰鬼與邪靈
        • 無所不在的陷阱—邪靈的詭計
        • 鬼屋‧符令‧大揭祕
        • 妖魔鬼怪大變身—邪靈與動物
        • 乩童與宮廟的祕密
        • 活鬼纏身的恐怖亂象
        • 前面親兄弟、後面無情義的假面
        • 前面手牽手、後面下毒手的險境
        • 人心險惡、五馬分屍所逼自殺的真相
        • 自殺後的奇遇—人死後的世界
        • 我自殺後的奇遇—「陰間地府處」與「陰府大本營」
        • 我被趕出家門、面臨眾叛親離的苦境
        • 其他
    • 英文學習

      • 自我介紹
      • 簡單寒暄
      • 指路
      • 買賣
      • 轉接電話
      • 通勤
      • 訂位
      • 數字
    • 金剛經 時間不存在?自我是虛擬?
    • 20250319 外婆過世
    • 說服的藝術:避免陷阱,建立共識
    • 禪修一定要去除「如何」嗎?
    • 不對稱的回報
    • 避免慢性壓力
    • 關鍵溝通表達力
    • 快樂的秘訣
    • 生活在現代社會,維持高品質思考的重要性
    • 心理學家的面相術:解讀情緒的密碼
    • 什麼是真相,真相是什麼
    • 身為一位歷史觀察者(文長慎入)
    • 2022 Leisure Learn
    • 兩年八個月,我從 Garena 畢業了
    • DeFi
    • 我的終生大事,交往到結婚
    • 轉職前端工程師 3 年多的回顧
    • MacOS + Iterm2 + Oh My Zsh
    • 我喜歡的名言佳句
    • Web Interview Preparation

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

Share On:

FacebookLINEMessengerTelegram

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

前言

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

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

成果

為避免文長內容過於單調,先把成果放在這邊,不想看一堆程式碼的可以直接點我看成果,由於本人經濟拮据,租不起效能比較好的 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;

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

Share On:

FacebookLINEMessengerTelegram

結語

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

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

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

最近更新: 2025/6/1 下午2:35
Contributors: johnnywang1994, johnnywang, Lindy Liao
Prev
快速上手 NextJS v13 - Data Fetching, Caching, Revalidating 篇
Next
如何只用一支 CDN 及 4行設定,讓瀏覽器讀懂 Typescript, React, Vue