วิธีสร้างเกมที่มีผู้ใช้หลายคนแบบเรียลไทม์ตั้งแต่เริ่มต้น

เผยแพร่แล้ว: 2022-03-10
สรุปอย่างย่อ ↬ บทความนี้เน้นที่กระบวนการ การตัดสินใจทางเทคนิค และบทเรียนที่เรียนรู้เบื้องหลังการสร้างเกม Autowuzzler แบบเรียลไทม์ เรียนรู้วิธีแชร์สถานะเกมกับไคลเอนต์หลายตัวแบบเรียลไทม์ด้วย Colyseus ทำการคำนวณทางฟิสิกส์ด้วย Matter.js จัดเก็บข้อมูลใน Supabase.io และสร้างส่วนหน้าด้วย SvelteKit

เมื่อเกิดการระบาดใหญ่ขึ้น ทีมที่อยู่ห่างไกลกันอย่างกะทันหันที่ฉันทำงานด้วยก็ถูกกีดกันจากฟุตบอลมากขึ้นเรื่อยๆ ฉันเคยคิดว่าจะเล่นฟุตบอลในสถานที่ห่างไกลได้อย่างไร แต่เห็นได้ชัดว่าการสร้างกฎของฟุตบอลบนหน้าจอขึ้นมาใหม่อาจไม่สนุกนัก

สิ่ง ที่ สนุกคือการเตะบอลด้วยรถของเล่น — เป็นความตระหนักที่เกิดขึ้นในขณะที่ฉันกำลังเล่นกับลูกวัย 2 ขวบของฉัน ในคืนเดียวกันนั้น ฉันได้ออกเดินทางเพื่อสร้างต้นแบบแรกสำหรับเกมที่จะกลายเป็น Autowuzzler

แนวคิดง่ายๆ : ผู้เล่นบังคับรถของเล่นเสมือนจริงในเวทีจากบนลงล่างที่คล้ายกับโต๊ะฟุตบอล ทีมแรกที่ทำคะแนนได้ 10 ประตูชนะ

แน่นอน แนวคิดในการใช้รถยนต์เพื่อเล่นฟุตบอลนั้นไม่เหมือนกัน แต่แนวคิดหลักสองข้อควรแยก Autowuzzler ออกจากกัน: ฉันต้องการสร้างรูปลักษณ์และความรู้สึกของการเล่นบนโต๊ะฟุตบอลจริง ๆ และฉันต้องการให้แน่ใจว่ามันเป็น ง่ายที่สุดที่จะเชิญเพื่อนหรือเพื่อนร่วมทีมมาเล่นเกมสบาย ๆ

ในบทความนี้ ฉันจะอธิบาย กระบวนการเบื้องหลังการสร้าง Autowuzzler ซึ่งเครื่องมือและเฟรมเวิร์กที่ฉันเลือก และแบ่งปันรายละเอียดการใช้งานบางส่วนและบทเรียนที่ฉันได้เรียนรู้

อินเทอร์เฟซผู้ใช้เกมแสดงพื้นหลังโต๊ะฟุตบอล รถหกคันในสองทีมและหนึ่งลูก
Autowuzzler (เบต้า) ที่มีผู้เล่นพร้อมกันหกคนในสองทีม (ตัวอย่างขนาดใหญ่)

ต้นแบบการทำงานครั้งแรก (แย่มาก)

ต้นแบบแรกถูกสร้างขึ้นโดยใช้เอ็นจิ้นเกมโอเพนซอร์ส Phaser.js ส่วนใหญ่สำหรับเอ็นจิ้นฟิสิกส์ที่รวมอยู่และเพราะฉันมีประสบการณ์กับมันมาแล้ว เวทีเกมถูกฝังอยู่ในแอปพลิเคชัน Next.js อีกครั้ง เพราะฉันเข้าใจ Next.js เป็นอย่างดีและต้องการเน้นที่เกมเป็นหลัก

เนื่องจากเกมต้อง รองรับผู้เล่นหลายคนแบบเรียลไทม์ ฉันจึงใช้ Express เป็นนายหน้า WebSockets ที่นี่เป็นที่ที่มันจะกลายเป็นเรื่องยุ่งยากแม้ว่า

เนื่องจากการคำนวณทางฟิสิกส์เสร็จสิ้นในไคลเอนต์ในเกม Phaser ฉันจึงเลือกตรรกะที่เรียบง่าย แต่มีข้อบกพร่องอย่างเห็นได้ชัด: ไคลเอนต์ที่เชื่อมต่อ ครั้งแรก มีสิทธิ์อย่างน่าสงสัยในการคำนวณฟิสิกส์สำหรับวัตถุเกมทั้งหมดโดยส่งผลไปยังเซิร์ฟเวอร์ด่วน ซึ่งจะถ่ายทอดตำแหน่ง มุม และแรงที่อัปเดตแล้วกลับไปยังลูกค้าของผู้เล่นคนอื่น ลูกค้ารายอื่นจะใช้การเปลี่ยนแปลงกับวัตถุเกม

สิ่งนี้นำไปสู่สถานการณ์ที่ผู้เล่น คนแรก ได้ เห็นฟิสิกส์เกิดขึ้นแบบเรียลไทม์ (มันเกิดขึ้นในเบราว์เซอร์ของพวกเขาเอง) ในขณะที่ผู้เล่นอื่น ๆ ทั้งหมดล้าหลังอย่างน้อย 30 มิลลิวินาที (อัตราการออกอากาศที่ฉันเลือก ) หรือ — หากการเชื่อมต่อเครือข่ายของผู้เล่น คนแรก ช้า — แย่กว่านั้นมาก

หากฟังดูเป็นสถาปัตยกรรมที่ไม่ดีสำหรับคุณ คุณพูดถูกจริงๆ อย่างไรก็ตาม ฉันยอมรับความจริงข้อนี้เพื่อให้ได้รับสิ่งที่สามารถเล่นได้อย่างรวดเร็วเพื่อดูว่าเกมนี้ น่า เล่นจริงหรือไม่

ตรวจสอบความคิด ทิ้งต้นแบบ

เนื่องจากการใช้งานมีข้อบกพร่อง จึงสามารถเล่นได้เพียงพอที่จะเชิญเพื่อน ๆ มาทดลองขับครั้งแรก ผลตอบรับเป็นไปในเชิงบวกอย่างมาก โดยข้อกังวลหลักคือ — ไม่น่าแปลกใจ — ประสิทธิภาพแบบเรียลไทม์ ปัญหาโดยธรรมชาติอื่น ๆ รวมถึงสถานการณ์เมื่อผู้เล่น คนแรก (จำได้ว่าเป็นผู้รับผิดชอบ ทุกอย่าง ) ออกจากเกม - ใครควรเข้าครอบครอง? ณ จุดนี้มีห้องเล่นเกมเพียงห้องเดียว ทุกคนจึงเข้าร่วมเกมเดียวกัน ฉันยังกังวลเล็กน้อยกับขนาดบันเดิลที่ไลบรารี Phaser.js แนะนำ

ถึงเวลาทิ้งต้นแบบและเริ่มต้นด้วยการตั้งค่าใหม่และเป้าหมายที่ชัดเจน

การติดตั้งโครงการ

เห็นได้ชัดว่าวิธีการ "ไคลเอนต์แรกควบคุมทั้งหมด" จำเป็นต้องถูกแทนที่ด้วยโซลูชันที่ สถานะเกมอยู่บนเซิร์ฟเวอร์ ในการวิจัยของฉัน ฉันได้พบกับ Colyseus ซึ่งฟังดูเหมือนเครื่องมือที่สมบูรณ์แบบสำหรับงานนี้

สำหรับองค์ประกอบหลักอื่นๆ ของเกม ฉันเลือก:

  • Matter.js เป็นเอ็นจิ้นฟิสิกส์แทน Phaser.js เพราะมันทำงานใน Node และ Autowuzzler ไม่ต้องการเฟรมเวิร์กเกมแบบเต็ม
  • SvelteKit เป็นเฟรมเวิร์กของแอปพลิเคชันแทนที่จะเป็น Next.js เนื่องจาก เพิ่ง เข้าสู่เวอร์ชันเบต้าสาธารณะในขณะนั้น (นอกจากนี้: ฉันชอบทำงานกับ Svelte)
  • Supabase.io สำหรับจัดเก็บ PIN เกมที่ผู้ใช้สร้างขึ้น

ดูรายละเอียดเพิ่มเติมที่หน่วยการสร้างเหล่านั้น

เพิ่มเติมหลังกระโดด! อ่านต่อด้านล่าง↓

ซิงโครไนซ์สถานะเกมแบบรวมศูนย์ด้วย Colyseus

Colyseus เป็นเฟรมเวิร์กเกมแบบผู้เล่นหลายคนที่ใช้ Node.js และ Express ที่แกนกลางของมันให้:

  • การซิงโครไนซ์สถานะระหว่างลูกค้าในลักษณะที่เชื่อถือได้
  • การสื่อสารแบบเรียลไทม์อย่างมีประสิทธิภาพโดยใช้ WebSockets โดยส่งข้อมูลที่เปลี่ยนแปลงเท่านั้น
  • การตั้งค่าหลายห้อง
  • ไลบรารีไคลเอนต์สำหรับ JavaScript, Unity, Defold Engine, Haxe, Cocos Creator, Construct3;
  • ขอวงจรชีวิต เช่น สร้างห้อง การรวมผู้ใช้ การออกจากผู้ใช้ และอื่นๆ
  • การส่งข้อความ ไม่ว่าจะเป็นข้อความที่แพร่ภาพไปยังผู้ใช้ทุกคนในห้องแชท หรือถึงผู้ใช้รายเดียว
  • แผงตรวจสอบในตัวและเครื่องมือทดสอบการโหลด

หมายเหตุ : เอกสาร Colyseus ทำให้การเริ่มต้นใช้งานเซิร์ฟเวอร์ Barebones Colyseus เป็นเรื่องง่าย โดยการจัดเตรียมสคริปต์ npm init และที่เก็บตัวอย่าง

การสร้างแบบแผน

เอนทิตีหลักของแอป Colyseus คือห้องเกม ซึ่งมีสถานะสำหรับอินสแตนซ์ห้องเดียวและวัตถุในเกมทั้งหมด ในกรณีของ Autowuzzler เป็นเซสชันเกมที่มี:

  • สองทีม,
  • ผู้เล่นจำนวนจำกัด
  • หนึ่งลูก

ต้องกำหนดสคีมาสำหรับคุณสมบัติทั้งหมดของอ็อบเจ็กต์เกมที่ ควรซิงโครไนซ์ระหว่างไคลเอนต์ ตัวอย่างเช่น เราต้องการให้ลูกบอลซิงโครไนซ์ ดังนั้นเราจึงจำเป็นต้องสร้างสคีมาสำหรับลูกบอล:

 class Ball extends Schema { constructor() { super(); this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; } } defineTypes(Ball, { x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number" });

ในตัวอย่างข้างต้น คลาสใหม่ที่ขยายคลาสสคีมาที่ Colyseus จัดเตรียมไว้ให้ ในตัวสร้าง คุณสมบัติทั้งหมดจะได้รับค่าเริ่มต้น ตำแหน่งและการเคลื่อนที่ของลูกบอลอธิบายโดยใช้คุณสมบัติห้าประการ: x , y , angle , velocityX, X, velocityY Y นอกจากนี้ เราต้อง ระบุประเภทของแต่ละคุณสมบัติ ตัวอย่างนี้ใช้ไวยากรณ์ JavaScript แต่คุณยังสามารถใช้ไวยากรณ์ TypeScript ที่กะทัดรัดกว่านี้ได้อีกเล็กน้อย

ประเภทพร็อพเพอร์ตี้สามารถเป็นประเภทดั้งเดิมได้:

  • string
  • boolean
  • number (เช่นเดียวกับจำนวนเต็มและประเภททศนิยมที่มีประสิทธิภาพมากขึ้น)

หรือประเภทที่ซับซ้อน:

  • ArraySchema (คล้ายกับ Array ใน JavaScript)
  • MapSchema (คล้ายกับ Map ใน JavaScript)
  • SetSchema (คล้ายกับ Set ใน JavaScript)
  • CollectionSchema (คล้ายกับ ArraySchema แต่ไม่มีการควบคุมดัชนี)

คลาส Ball ด้านบนมีคุณสมบัติห้าประการของ number ประเภท: พิกัด ( x , y ) angle ปัจจุบัน และเวกเตอร์ความเร็ว ( velocityX , velocityY )

สคีมาสำหรับผู้เล่นมีความคล้ายคลึงกัน แต่มีคุณสมบัติอื่นๆ อีกสองสามอย่างในการจัดเก็บชื่อผู้เล่นและหมายเลขของทีม ซึ่งจำเป็นต้องระบุเมื่อสร้างอินสแตนซ์ของผู้เล่น:

 class Player extends Schema { constructor(teamNumber) { super(); this.name = ""; this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; this.teamNumber = teamNumber; } } defineTypes(Player, { name: "string", x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number", angularVelocity: "number", teamNumber: "number", });

สุดท้าย สคีมาสำหรับ Room Autowuzzler จะ เชื่อมต่อคลาสที่กำหนดไว้ก่อนหน้านี้: อินสแตนซ์ห้องหนึ่งมีหลายทีม (เก็บไว้ใน ArraySchema) นอกจากนี้ยังมีลูกบอลเดี่ยว ดังนั้นเราจึงสร้างอินสแตนซ์ Ball ใหม่ในตัวสร้างของ RoomSchema ผู้เล่นจะถูกเก็บไว้ใน MapSchema เพื่อการดึงข้อมูลอย่างรวดเร็วโดยใช้ ID ของพวกเขา

 class RoomSchema extends Schema { constructor() { super(); this.teams = new ArraySchema(); this.ball = new Ball(); this.players = new MapSchema(); } } defineTypes(RoomSchema, { teams: [Team], // an Array of Team ball: Ball, // a single Ball instance players: { map: Player } // a Map of Players });
หมายเหตุ : ละเว้นคำจำกัดความของคลาส Team

การตั้งค่าหลายห้อง (“การจับคู่”)

ทุกคนสามารถเข้าร่วมเกม Autowuzzler ได้หากมี PIN เกมที่ถูกต้อง เซิร์ฟเวอร์ Colyseus ของเราสร้างอินสแตนซ์ห้องใหม่สำหรับทุกเซสชั่นเกมทันทีที่ผู้เล่นคนแรกเข้าร่วมและทิ้งห้องเมื่อผู้เล่นคนสุดท้ายออกจากห้อง

กระบวนการ กำหนดผู้เล่นไปยังห้องเกมที่ต้องการ เรียกว่า "การจับคู่" Colyseus ทำให้ง่ายต่อการติดตั้งโดยใช้วิธี filterBy เมื่อกำหนดห้องใหม่:

 gameServer.define("autowuzzler", AutowuzzlerRoom).filterBy(['gamePIN']);

ตอนนี้ผู้เล่นทุกคนที่เข้าร่วมเกมด้วย gamePIN ของเกมเดียวกัน (เราจะดูวิธีการ "เข้าร่วม" ในภายหลัง) จะจบลงที่ห้องเกมเดียวกัน! การอัปเดตสถานะและข้อความออกอากาศอื่น ๆ นั้น จำกัด เฉพาะผู้เล่นในห้องเดียวกัน

ฟิสิกส์ในแอป Colyseus

Colyseus นำเสนอสิ่งที่ทำได้ทันทีเพื่อเริ่มต้นและใช้งานอย่างรวดเร็วด้วยเซิร์ฟเวอร์เกมที่เชื่อถือได้ แต่ปล่อยให้นักพัฒนาสร้างกลไกของเกมที่แท้จริง ซึ่งรวมถึงฟิสิกส์ Phaser.js ซึ่งฉันใช้ในต้นแบบนั้นไม่สามารถดำเนินการได้ในสภาพแวดล้อมที่ไม่ใช่เบราว์เซอร์ แต่ Matter.js เอ็นจิ้นฟิสิกส์แบบรวมของ Phaser.js สามารถทำงานบน Node.js ได้

ด้วย Matter.js คุณจะกำหนดโลกแห่งฟิสิกส์ด้วยคุณสมบัติทางกายภาพบางอย่าง เช่น ขนาดและแรงโน้มถ่วง มีหลายวิธีในการสร้างวัตถุฟิสิกส์ดั้งเดิมซึ่งมีปฏิสัมพันธ์ซึ่งกันและกันโดยปฏิบัติตามกฎ (จำลอง) ของฟิสิกส์ รวมถึงมวล การชน การเคลื่อนไหวด้วยแรงเสียดทาน และอื่นๆ คุณสามารถ เคลื่อนย้ายสิ่งของไปรอบๆ ได้โดยใช้กำลัง เช่นเดียวกับในโลกแห่งความเป็นจริง

“โลก” ของ A Matter.js อยู่ที่หัวใจของเกม Autowuzzler ; มันกำหนดว่ารถเคลื่อนที่เร็วแค่ไหน บอลจะเด้งขนาดไหน ตั้งเป้าหมายไว้ที่ไหน และจะเกิดอะไรขึ้นถ้ามีคนยิงประตู

 let ball = Bodies.circle( ballInitialXPosition, ballInitialYPosition, radius, { render: { sprite: { texture: '/assets/ball.png', } }, friction: 0.002, restitution: 0.8 } ); World.add(this.engine.world, [ball]);

รหัสแบบง่ายสำหรับการเพิ่มวัตถุเกม "บอล" ลงในพื้นที่งานใน Matter.js

เมื่อกำหนดกฎแล้ว Matter.js สามารถทำงาน โดยมี หรือ ไม่มี การแสดงบางสิ่งบนหน้าจอจริงๆ สำหรับ Autowuzzler ฉันใช้คุณลักษณะนี้เพื่อนำรหัสโลกของฟิสิกส์มาใช้ซ้ำสำหรับทั้งเซิร์ฟเวอร์ และ ไคลเอ็นต์ โดยมีความแตกต่างที่สำคัญหลายประการ:

โลกฟิสิกส์ บนเซิร์ฟเวอร์ :

  • รับข้อมูลจากผู้ใช้ (เหตุการณ์แป้นพิมพ์สำหรับบังคับรถ) ผ่าน Colyseus และใช้กำลังที่เหมาะสมกับวัตถุในเกม (รถของผู้ใช้)
  • ทำการคำนวณทางฟิสิกส์ทั้งหมดสำหรับวัตถุทั้งหมด (ผู้เล่นและลูกบอล) รวมถึงการตรวจจับการชน
  • แจ้งสถานะที่อัปเดตสำหรับวัตถุแต่ละเกมกลับไปยัง Colyseus ซึ่งจะถ่ายทอดไปยังไคลเอนต์
  • ได้รับการอัปเดตทุกๆ 16.6 มิลลิวินาที (= 60 เฟรมต่อวินาที) ซึ่งถูกเรียกใช้โดยเซิร์ฟเวอร์ Colyseus ของเรา

โลกฟิสิกส์ บนไคลเอนต์ :

  • ไม่จัดการวัตถุในเกมโดยตรง
  • ได้รับสถานะที่อัปเดตสำหรับวัตถุแต่ละเกมจาก Colyseus;
  • ใช้การเปลี่ยนแปลงในตำแหน่ง ความเร็ว และมุมหลังจากได้รับสถานะที่ปรับปรุงแล้ว
  • ส่งข้อมูลของผู้ใช้ (เหตุการณ์แป้นพิมพ์สำหรับบังคับรถ) ไปยัง Colyseus;
  • โหลดสไปรท์เกมและใช้ตัวแสดงภาพเพื่อวาดโลกฟิสิกส์ลงบนองค์ประกอบผ้าใบ
  • ข้ามการตรวจจับการชนกัน (โดยใช้ตัวเลือก isSensor สำหรับวัตถุ);
  • อัปเดตโดยใช้ requestAnimationFrame ที่ 60 fps
ไดอะแกรมแสดงสองช่วงตึกหลัก: Colyseus Server App และ SvelteKit App แอปเซิร์ฟเวอร์ Colyseus มีบล็อกห้อง Autowuzzler แอป SvelteKit มีบล็อกไคลเอ็นต์ Colyseus บล็อกหลักทั้งสองใช้บล็อกชื่อ Physics World (Matter.js)
หน่วยตรรกะหลักของสถาปัตยกรรม Autowuzzler: Physics World ถูกแชร์ระหว่างเซิร์ฟเวอร์ Colyseus และแอปไคลเอ็นต์ SvelteKit (ตัวอย่างขนาดใหญ่)

ขณะนี้ เมื่อมีเวทมนตร์ทั้งหมดเกิดขึ้นบนเซิร์ฟเวอร์ ไคลเอ็นต์จะจัดการเฉพาะอินพุตและดึงสถานะที่ได้รับจากเซิร์ฟเวอร์ไปยังหน้าจอ ด้วยข้อยกเว้นประการหนึ่ง:

การแก้ไขเกี่ยวกับลูกค้า

เนื่องจากเราใช้โลกฟิสิกส์ของ Matter.js เดิมกับไคลเอนต์ซ้ำ เราจึงสามารถปรับปรุงประสิทธิภาพที่มีประสบการณ์ด้วยกลอุบายง่ายๆ แทนที่จะอัปเดตเฉพาะตำแหน่งของวัตถุในเกม เรายัง ซิงโครไนซ์ความเร็วของวัตถุ ด้วย ด้วยวิธีนี้ ออบเจ็กต์จะยังคงเคลื่อนที่ต่อไปในวิถีของมัน แม้ว่าการอัปเดตครั้งต่อไปจากเซิร์ฟเวอร์จะใช้เวลานานกว่าปกติก็ตาม ดังนั้น แทนที่จะย้ายวัตถุเป็นขั้นแยกจากตำแหน่ง A ไปยังตำแหน่ง B เราเปลี่ยนตำแหน่งของวัตถุและทำให้มันเคลื่อนที่ไปในทิศทางที่แน่นอน

วงจรชีวิต

Room Autowuzzler เป็นห้องที่มีการจัดการตรรกะที่เกี่ยวข้องกับขั้นตอนต่างๆ ของห้อง Colyseus Colyseus มีวิธีวงจรชีวิตหลายวิธี:

  • onCreate : เมื่อมีการสร้างห้องใหม่ (โดยปกติเมื่อลูกค้ารายแรกเชื่อมต่อ);
  • onAuth : เป็นขออนุญาตเพื่ออนุญาตหรือปฏิเสธการเข้าห้อง
  • onJoin : เมื่อลูกค้าเชื่อมต่อกับห้อง
  • onLeave : เมื่อลูกค้ายกเลิกการเชื่อมต่อจากห้อง
  • onDispose : เมื่อทิ้งห้อง

ห้อง Autowuzzler สร้างตัวอย่างใหม่ของโลกฟิสิกส์ (ดูหัวข้อ "ฟิสิกส์ในแอป Colyseus") ทันทีที่สร้างขึ้น ( onCreate ) และเพิ่มผู้เล่นเข้าสู่โลกเมื่อลูกค้าเชื่อมต่อ ( onJoin ) จากนั้นจะอัปเดตโลกฟิสิกส์ 60 ครั้งต่อวินาที (ทุกๆ 16.6 มิลลิวินาที) โดยใช้เมธอด setSimulationInterval (ลูปเกมหลักของเรา):

 // deltaTime is roughly 16.6 milliseconds this.setSimulationInterval((deltaTime) => this.world.updateWorld(deltaTime));

วัตถุฟิสิกส์ไม่ขึ้นกับวัตถุของ Colyseus ซึ่งทำให้เรามี การเรียงสับเปลี่ยนสองครั้งของวัตถุในเกมเดียวกัน (เช่น ลูกบอล) นั่นคือวัตถุในโลกฟิสิกส์และวัตถุ Colyseus ที่สามารถซิงค์ได้

ทันทีที่ฟิสิคัลอ็อบเจ็กต์เปลี่ยนแปลง คุณสมบัติที่อัพเดตจะต้องถูกนำไปใช้กับออบเจกต์ Colyseus เราสามารถทำได้โดยการฟังเหตุการณ์ afterUpdate ของ afterUpdate และตั้งค่าจากที่นั่น:

 Events.on(this.engine, "afterUpdate", () => { // apply the x position of the physics ball object back to the colyseus ball object this.state.ball.x = this.physicsWorld.ball.position.x; // ... all other ball properties // loop over all physics players and apply their properties back to colyseus players objects })

มีวัตถุอีกหนึ่งชุดที่เราต้องดูแล นั่นคือ วัตถุในเกมในเกมที่ผู้ใช้ต้องเผชิญ

ไดอะแกรมแสดงอ็อบเจ็กต์เกมสามเวอร์ชัน: Colyseus Schema Objects, Matter.js Physics Objects, Client Matter.js Physics Objects Matter.js อัปเดตเวอร์ชันของอ็อบเจ็กต์ Colyseus โดย Colyseus จะซิงโครไนซ์กับ Client Matter.js Physics Object
Autowuzzler เก็บรักษาสำเนาสามชุดของวัตถุฟิสิกส์แต่ละรายการ หนึ่งรุ่นที่เชื่อถือได้ (วัตถุ Colyseus) รุ่นหนึ่งในโลกฟิสิกส์ของ Matter.js และรุ่นหนึ่งบนไคลเอนต์ (ตัวอย่างขนาดใหญ่)

แอปพลิเคชันฝั่งไคลเอ็นต์

ตอนนี้ เรามีแอปพลิเคชันบนเซิร์ฟเวอร์ที่จัดการการซิงโครไนซ์สถานะเกมสำหรับหลายห้องรวมถึงการคำนวณทางฟิสิกส์ มาเน้นที่ การสร้างเว็บไซต์และอินเทอร์เฟซเกมจริง ส่วนหน้า Autowuzzler มีหน้าที่ดังต่อไปนี้:

  • ให้ผู้ใช้สร้างและแชร์ PIN ของเกมเพื่อเข้าถึงแต่ละห้อง
  • ส่ง PIN เกมที่สร้างขึ้นไปยังฐานข้อมูล Supabase เพื่อความคงอยู่
  • ให้หน้าตัวเลือก "เข้าร่วมเกม" เพื่อให้ผู้เล่นป้อน PIN ของเกม
  • ตรวจสอบ PIN ของเกมเมื่อผู้เล่นเข้าร่วมเกม
  • โฮสต์และแสดงผลเกมจริงบน URL ที่แชร์ได้ (เช่น เฉพาะ)
  • เชื่อมต่อกับเซิร์ฟเวอร์ Colyseus และจัดการการอัปเดตสถานะ
  • ให้หน้า Landing Page ("การตลาด")

สำหรับการใช้งานเหล่านั้น ฉันเลือก SvelteKit แทน Next.js ด้วยเหตุผลดังต่อไปนี้:

ทำไมต้อง SvelteKit?

ฉันต้องการพัฒนาแอปอื่นโดยใช้ Svelte นับตั้งแต่ฉันสร้าง neolightsout เมื่อ SvelteKit (เฟรมเวิร์กของแอปพลิเคชันอย่างเป็นทางการสำหรับ Svelte) เข้าสู่เวอร์ชันเบต้าสาธารณะ ฉันตัดสินใจสร้าง Autowuzzler ด้วยและยอมรับความปวดหัวที่มาพร้อมกับการใช้เบต้าใหม่ — ความสุขของการใช้ Svelte นั้นชดเชยได้อย่างชัดเจน

คุณสมบัติหลัก เหล่านี้ทำให้ฉันเลือก SvelteKit เหนือ Next.js สำหรับการใช้งานส่วนหน้าของเกมจริง:

  • Svelte เป็นเฟรมเวิร์ก UI และ คอมไพเลอร์ ดังนั้นจึงส่งโค้ดขั้นต่ำโดยไม่มีรันไทม์ของไคลเอ็นต์
  • Svelte มีเทมเพลตภาษาและระบบองค์ประกอบที่แสดงออก (ความชอบส่วนตัว);
  • Svelte ประกอบด้วยร้านค้า ทรานสิชั่น และแอนิเมชั่นระดับโลก ซึ่งหมายความว่า: ไม่มีการตัดสินใจเมื่อยล้าในการเลือกชุดเครื่องมือการจัดการสถานะทั่วโลกและไลบรารีแอนิเมชั่น
  • Svelte รองรับ CSS ที่มีขอบเขตในองค์ประกอบไฟล์เดียว
  • SvelteKit รองรับ SSR การกำหนดเส้นทางตามไฟล์ที่เรียบง่าย แต่ยืดหยุ่น และเส้นทางฝั่งเซิร์ฟเวอร์สำหรับการสร้าง API
  • SvelteKit อนุญาตให้แต่ละเพจรันโค้ดบนเซิร์ฟเวอร์ เช่น ดึงข้อมูลที่ใช้เพื่อแสดงเพจ
  • เค้าโครงที่ใช้ร่วมกันระหว่างเส้นทาง
  • SvelteKit สามารถเรียกใช้ในสภาพแวดล้อมแบบไร้เซิร์ฟเวอร์

การสร้างและจัดเก็บ PIN ของเกม

ก่อนที่ผู้ใช้จะเริ่มเล่นเกมได้ ผู้ใช้จะต้องสร้าง PIN เกมก่อน ด้วยการแบ่งปัน PIN กับผู้อื่น พวกเขาทั้งหมดสามารถเข้าถึงห้องเกมเดียวกันได้

สกรีนช็อตของส่วนการเริ่มเกมใหม่ของเว็บไซต์ Autowuzzler ที่แสดง PIN เกม 751428 และตัวเลือกในการคัดลอกและแบ่งปัน PIN และ URL ของเกม
เริ่มเกมใหม่โดยคัดลอก PIN เกมที่สร้างขึ้นหรือแชร์ลิงก์โดยตรงไปยังห้องเกม (ตัวอย่างขนาดใหญ่)

นี่เป็นกรณีการใช้งานที่ยอดเยี่ยมสำหรับอุปกรณ์ปลายทางฝั่งเซิร์ฟเวอร์ SvelteKits ร่วมกับฟังก์ชัน Sveltes onMount: ปลายทาง /api/createcode จะสร้าง PIN ของเกม เก็บไว้ในฐานข้อมูล Supabase.io และ ส่งออก PIN ของเกมเป็นการตอบกลับ นี่คือการตอบกลับที่ดึงออกมาทันทีที่ส่วนประกอบหน้าของหน้า "สร้าง" ถูกติดตั้ง:

ไดอะแกรมแสดงสามส่วน: สร้างเพจ, สร้างปลายทางของ createcode และ Supabase.io สร้างเพจดึงข้อมูลปลายทางในฟังก์ชัน onMount ปลายทางสร้าง PIN ของเกม เก็บไว้ใน Supabase.io และตอบกลับด้วย PIN ของเกม หน้าสร้างจะแสดง PIN ของเกม
PIN ของเกมจะถูกสร้างขึ้นในจุดสิ้นสุด ซึ่งจัดเก็บไว้ในฐานข้อมูล Supabase.io และแสดงบนหน้า "สร้าง" (ตัวอย่างขนาดใหญ่)

การจัดเก็บ PIN เกมด้วย Supabase.io

Supabase.io เป็นทางเลือกโอเพ่นซอร์สสำหรับ Firebase Supabase ทำให้ง่ายต่อการสร้างฐานข้อมูล PostgreSQL และเข้าถึงได้ผ่านหนึ่งในไลบรารีของไคลเอ็นต์หรือผ่าน REST

สำหรับไคลเอนต์ JavaScript เรานำเข้าฟังก์ชัน createClient และดำเนินการโดยใช้พารามิเตอร์ supabase_url และ supabase_key ที่เราได้รับเมื่อสร้างฐานข้อมูล ในการ จัดเก็บ PIN เกม ที่สร้างขึ้นในแต่ละการโทรไปยังปลายทาง createcode ทั้งหมดที่เราต้องทำคือเรียกใช้แบบสอบถามแบบ insert ง่ายๆ นี้:

 import { createClient } from '@supabase/supabase-js' const database = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_KEY ); const { data, error } = await database .from("games") .insert([{ code: 123456 }]);

หมายเหตุ : supabase_url และ supabase_key ถูกเก็บไว้ในไฟล์ .env เนื่องจาก Vite ซึ่งเป็นเครื่องมือสร้างที่เป็นหัวใจสำคัญของ SvelteKit จึงจำเป็นต้องเติมคำนำหน้าตัวแปรสภาพแวดล้อมด้วย VITE_ เพื่อให้สามารถเข้าถึงได้ใน SvelteKit

การเข้าถึงเกม

ฉันต้องการทำให้การเข้าร่วมเกม Autowuzzler ทำได้ง่ายเพียงแค่คลิกลิงก์ ดังนั้น ทุกห้องเกมจำเป็นต้องมี URL ของตัวเองตาม PIN เกมที่สร้างไว้ก่อนหน้านี้ เช่น https://autowuzzler.com/play/12345

ใน SvelteKit เพจที่มีพารามิเตอร์เส้นทางแบบไดนามิกจะถูกสร้างขึ้นโดยใส่ส่วนไดนามิกของเส้นทางในวงเล็บเหลี่ยมเมื่อตั้งชื่อไฟล์เพจ: client/src/routes/play/[gamePIN].svelte ค่าของพารามิเตอร์ gamePIN จะพร้อมใช้งานในองค์ประกอบของหน้า (ดูรายละเอียดในเอกสาร SvelteKit) ในเส้นทางการ play เราต้องเชื่อมต่อกับเซิร์ฟเวอร์ Colyseus สร้างโลกฟิสิกส์เพื่อแสดงผลบนหน้าจอ จัดการการอัปเดตวัตถุในเกม ฟังอินพุตคีย์บอร์ด และแสดง UI อื่นๆ เช่น คะแนน และอื่นๆ

กำลังเชื่อมต่อกับ Colyseus และสถานะการอัปเดต

ไลบรารีไคลเอนต์ Colyseus ช่วยให้เราสามารถเชื่อมต่อไคลเอนต์กับเซิร์ฟเวอร์ Colyseus ขั้นแรก ให้สร้าง Colyseus.Client ใหม่โดยชี้ไปที่เซิร์ฟเวอร์ Colyseus ( ws://localhost:2567 กำลังพัฒนา) จากนั้นเข้าร่วมห้องด้วยชื่อที่เราเลือกไว้ก่อนหน้านี้ ( autowuzzler ) และ gamePIN จากพารามิเตอร์เส้นทาง พารามิเตอร์ gamePIN ช่วยให้แน่ใจว่าผู้ใช้เข้าร่วมห้องที่ถูกต้อง (ดู "การจับคู่" ด้านบน)

 let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN });

เนื่องจาก SvelteKit แสดงเพจบนเซิร์ฟเวอร์ในขั้นต้น เราจึงต้องตรวจสอบให้แน่ใจว่า โค้ดนี้ทำงานบนไคลเอนต์ หลังจากโหลดเพจเสร็จแล้วเท่านั้น อีกครั้ง เราใช้ฟังก์ชันวงจรชีวิต onMount สำหรับกรณีการใช้งานนั้น (หากคุณคุ้นเคยกับ React แล้ว onMount จะคล้ายกับ useEffect hook ที่มีอาร์เรย์การพึ่งพาที่ว่างเปล่า)

 onMount(async () => { let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN }); })

ตอนนี้เราได้เชื่อมต่อกับเซิร์ฟเวอร์เกม Colyseus แล้ว เราสามารถเริ่มฟังการเปลี่ยนแปลงใดๆ กับวัตถุในเกมของเราได้

ต่อไปนี้คือตัวอย่าง วิธีการฟังผู้เล่นที่เข้าร่วมห้อง ( onAdd ) และรับการอัปเดตสถานะต่อเนื่องของเครื่องเล่นนี้:

 this.room.state.players.onAdd = (player, key) => { console.log(`Player has been added with sessionId: ${key}`); // add player entity to the game world this.world.createPlayer(key, player.teamNumber); // listen for changes to this player player.onChange = (changes) => { changes.forEach(({ field, value }) => { this.world.updatePlayer(key, field, value); // see below }); }; };

ในวิธี updatePlayer ของโลกฟิสิกส์ เราอัปเดตคุณสมบัติทีละรายการเนื่องจาก onChange ของ Colyseus มอบชุดคุณสมบัติที่เปลี่ยนแปลงทั้งหมด

หมายเหตุ : ฟังก์ชันนี้ทำงานเฉพาะในเวอร์ชันไคลเอนต์ของโลกฟิสิกส์เท่านั้น เนื่องจากวัตถุในเกมได้รับการจัดการทางอ้อมผ่านเซิร์ฟเวอร์ Colyseus เท่านั้น

 updatePlayer(sessionId, field, value) { // get the player physics object by its sessionId let player = this.world.players.get(sessionId); // exit if not found if (!player) return; // apply changes to the properties switch (field) { case "angle": Body.setAngle(player, value); break; case "x": Body.setPosition(player, { x: value, y: player.position.y }); break; case "y": Body.setPosition(player, { x: player.position.x, y: value }); break; // set velocityX, velocityY, angularVelocity ... } }

ขั้นตอนเดียวกันนี้ใช้กับวัตถุเกมอื่น ๆ (บอลและทีม): ฟังการเปลี่ยนแปลงและนำค่าที่เปลี่ยนแปลงไปใช้กับโลกฟิสิกส์ของลูกค้า

จนถึงตอนนี้ ยังไม่มีวัตถุใดเคลื่อนไหวเพราะเรายังต้องการ ฟังการป้อนข้อมูลจากแป้นพิมพ์และส่งไปยังเซิร์ฟเวอร์ แทนที่จะส่งเหตุการณ์โดยตรงในทุกเหตุการณ์ที่กดคีย์ เราจะรักษาแผนที่ของปุ่มที่ถูกกดอยู่ และส่งเหตุการณ์ไปยังเซิร์ฟเวอร์ keydown ในระยะเวลา 50 มิลลิวินาที ด้วยวิธีนี้ เราสามารถรองรับการกดหลายปุ่มพร้อมกันและลดการหยุดชั่วคราวที่เกิดขึ้นหลังจากเหตุการณ์การกดปุ่มลงครั้งแรกและครั้ง keydown กันเมื่อกดปุ่มค้างไว้:

 let keys = {}; const keyDown = e => { keys[e.key] = true; }; const keyUp = e => { keys[e.key] = false; }; document.addEventListener('keydown', keyDown); document.addEventListener('keyup', keyUp); let loop = () => { if (keys["ArrowLeft"]) { this.room.send("move", { direction: "left" }); } else if (keys["ArrowRight"]) { this.room.send("move", { direction: "right" }); } if (keys["ArrowUp"]) { this.room.send("move", { direction: "up" }); } else if (keys["ArrowDown"]) { this.room.send("move", { direction: "down" }); } // next iteration requestAnimationFrame(() => { setTimeout(loop, 50); }); } // start loop setTimeout(loop, 50);

ตอนนี้วงจรสิ้นสุดลงแล้ว: ฟังการกดแป้นพิมพ์ ส่งคำสั่งที่เกี่ยวข้องไปยังเซิร์ฟเวอร์ Colyseus เพื่อจัดการโลกฟิสิกส์บนเซิร์ฟเวอร์ จากนั้นเซิร์ฟเวอร์ Colyseus จะนำคุณสมบัติทางกายภาพใหม่ไปใช้กับวัตถุเกมทั้งหมดและเผยแพร่ข้อมูลกลับไปยังไคลเอนต์เพื่ออัปเดตอินสแตนซ์ที่ผู้ใช้เผชิญหน้าของเกม

รบกวนเล็กน้อย

เมื่อมองย้อนกลับไป สองสิ่งที่อยู่ในหมวดหมู่นี้ที่ ไม่มีใครบอกผมแต่มีใครบางคนควร จะนึกถึง:

  • ความเข้าใจที่ดีเกี่ยวกับวิธีการทำงานของเอ็นจิ้นฟิสิกส์ นั้นมีประโยชน์ ฉันใช้เวลาพอสมควรในการปรับคุณสมบัติและข้อจำกัดทางฟิสิกส์อย่างละเอียด แม้ว่าฉันจะสร้างเกมเล็กๆ ด้วย Phaser.js และ Matter.js มาก่อน แต่ก็มีการลองผิดลองถูกมากมายที่จะทำให้วัตถุเคลื่อนไหวในแบบที่ฉันจินตนาการได้
  • เรียลไทม์นั้นยาก — โดยเฉพาะในเกมที่ใช้ฟิสิกส์เป็นหลัก ความล่าช้าเล็กน้อยทำให้ประสบการณ์แย่ลงอย่างมาก และในขณะที่การซิงโครไนซ์สถานะระหว่างไคลเอนต์กับ Colyseus นั้นใช้งานได้ดี แต่ก็ไม่สามารถขจัดความล่าช้าในการคำนวณและการส่งข้อมูลได้

Gotchas และคำเตือนด้วย SvelteKit

เนื่องจากฉันใช้ SvelteKit เมื่อเพิ่งออกจากเตาอบเบต้า จึงมี gotchas และคำเตือนบางประการที่ฉันต้องการจะชี้ให้เห็น:

  • ต้องใช้เวลาสักครู่ในการค้นหาว่าตัวแปรสภาพแวดล้อมต้องขึ้นต้นด้วย VITE_ เพื่อใช้ใน SvelteKit ขณะนี้มีการจัดทำเอกสารอย่างถูกต้องในคำถามที่พบบ่อย
  • ในการใช้ Supabase ฉันต้องเพิ่ม Supabase ให้กับ ทั้ง รายการการ dependencies และ devDependencies ของ package.json ฉันเชื่อว่านี่ไม่ใช่กรณีอีกต่อไป
  • ฟังก์ชั่น load SvelteKits ทำงานทั้งบนเซิร์ฟเวอร์ และ ไคลเอนต์!
  • ในการเปิดใช้งานการแทนที่โมดูล hot แบบเต็ม (รวมถึงสถานะการรักษา) คุณต้องเพิ่มบรรทัดความคิดเห็น <!-- @hmr:keep-all --> ด้วยตนเองในองค์ประกอบของเพจ ดูคำถามที่พบบ่อยสำหรับรายละเอียดเพิ่มเติม

กรอบงานอื่นๆ จำนวนมากก็เข้ากันได้ดีเช่นกัน แต่ฉันไม่เสียใจที่เลือก SvelteKit สำหรับโครงการนี้ มันทำให้ฉันสามารถทำงานกับแอปพลิเคชันไคลเอนต์ได้อย่างมีประสิทธิภาพ — ส่วนใหญ่เป็นเพราะตัว Svelte นั้นแสดงออกได้ดีมากและข้ามโค้ดสำเร็จรูปไปมากมาย แต่เนื่องจาก Svelte มีสิ่งต่าง ๆ เช่น แอนิเมชั่น ทรานสิชั่น CSS ที่มีขอบเขต และร้านค้าทั่วโลก SvelteKit จัดเตรียมหน่วยการสร้างทั้งหมดที่ฉันต้องการ (SSR, การกำหนดเส้นทาง, เส้นทางเซิร์ฟเวอร์) และแม้ว่าจะยังอยู่ในช่วงเบต้า แต่ก็รู้สึกเสถียรและรวดเร็วมาก

การปรับใช้และการโฮสต์

เริ่มแรก ฉันโฮสต์เซิร์ฟเวอร์ Colyseus (Node) บนอินสแตนซ์ของ Heroku และเสียเวลามากในการทำให้ WebSockets และ CORS ทำงานได้ ผลปรากฏว่าประสิทธิภาพของไดโน Heroku ขนาดเล็ก (ฟรี) นั้นไม่เพียงพอสำหรับกรณีการใช้งานแบบเรียลไทม์ ต่อมาฉันย้ายแอป Colyseus ไปยังเซิร์ฟเวอร์ขนาดเล็กที่ Linode แอปพลิเคชันฝั่งไคลเอ็นต์ถูกปรับใช้โดยและโฮสต์บน Netlify ผ่านอะแดปเตอร์ SvelteKits-netlify ไม่มีอะไรน่าประหลาดใจเลย: Netlify ทำงานได้ดีมาก!

บทสรุป

การเริ่มต้นด้วยต้นแบบที่เรียบง่ายจริงๆ เพื่อตรวจสอบแนวคิดนี้ช่วยฉันได้มากในการค้นหาว่าโครงการนี้น่าติดตามหรือไม่ และความท้าทายทางเทคนิคของเกมอยู่ที่ใด ในการใช้งานขั้นสุดท้าย Colyseus ได้ดูแลการยกสถานะการซิงโครไนซ์ทั้งหมดในแบบเรียลไทม์ผ่านไคลเอนต์หลายตัว กระจายอยู่ในหลายห้อง Colyseus สามารถสร้างแอปพลิเคชันแบบผู้ใช้หลายคนแบบเรียลไทม์ได้เร็วเพียงใด เป็นเรื่องที่น่าประทับใจ เมื่อคุณเข้าใจวิธีอธิบายสคีมาอย่างถูกต้องแล้ว แผงตรวจสอบในตัวของ Colyseus ช่วยในการแก้ไขปัญหาการซิงโครไนซ์

สิ่งที่ซับซ้อนในการตั้งค่านี้คือเลเยอร์ฟิสิกส์ของเกม เพราะมันได้แนะนำสำเนาเพิ่มเติมของวัตถุเกมที่เกี่ยวข้องกับฟิสิกส์แต่ละรายการที่ต้องได้รับการบำรุงรักษา การจัดเก็บ PIN เกมใน Supabase.io จากแอป SvelteKit นั้นตรงไปตรงมามาก เมื่อมองย้อนกลับไป ฉันสามารถใช้ฐานข้อมูล SQLite เพื่อจัดเก็บ PIN ของเกมได้ แต่ การลองสิ่งใหม่ ๆ นั้นสนุกเพียงครึ่งเดียวในการสร้างโปรเจ็กต์ด้านข้าง

ในที่สุด การใช้ SvelteKit เพื่อสร้างส่วนหน้าของเกมทำให้ฉันเคลื่อนไหวได้อย่างรวดเร็ว — และด้วยรอยยิ้มแห่งความสุขเป็นครั้งคราวบนใบหน้าของฉัน

ตอนนี้ไปข้างหน้าและเชิญเพื่อนของคุณเข้าสู่รอบ Autowuzzler!

อ่านเพิ่มเติมเกี่ยวกับ Smashing Magazine

  • “เริ่มต้นปฏิกิริยาด้วยการสร้างเกมตัวตุ่น” Jhey Tompkins
  • “วิธีสร้างเกมเสมือนจริงสำหรับผู้เล่นหลายคนแบบเรียลไทม์” Alvin Wan
  • “การเขียนเอ็นจิ้นการผจญภัยข้อความสำหรับผู้เล่นหลายคนใน Node.js” Fernando Doglio
  • “อนาคตของการออกแบบเว็บบนมือถือ: การออกแบบวิดีโอเกมและการเล่าเรื่อง” Suzanne Scacca
  • “วิธีสร้างเกมวิ่งที่ไม่มีที่สิ้นสุดในความเป็นจริงเสมือน” Alvin Wan