Entity-Component-System (ECS) เป็นรูปแบบการออกแบบสถาปัตยกรรมแบบกระจายและจัดองค์ประกอบซึ่งส่วนใหญ่ใช้ในการพัฒนาเกม ช่วยให้สามารถแยกพฤติกรรมเฉพาะโดเมนได้อย่างยืดหยุ่น ซึ่งเอาชนะข้อเสียหลายประการของการสืบทอดเชิงวัตถุแบบดั้งเดิม

Elixir คือภาษาแบบไดนามิกและใช้งานได้จริงซึ่งสร้างขึ้นจาก Erlang VM ซึ่งออกแบบมาเพื่อสร้างแอปพลิเคชันที่ปรับขนาดได้และบำรุงรักษาได้

ในบทความนี้ ค้นพบว่าเราสามารถใช้ทั้ง ECS และ Elixir ในแนวทางใหม่เพื่อจัดโครงสร้างโปรแกรมของเราให้เหนือกว่ากระบวนทัศน์การสืบทอดตามคลาสได้อย่างไร

ซอร์สโค้ดสำหรับการใช้งาน ECS ของฉันใน Elixir เป็นโอเพ่นซอร์สบน Github

ข้อเสียของการสืบทอดตามคลาส

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

เรากำลังสร้างเอ็นจิ้นเกม และเราพบว่าตัวเองอยู่ในลำดับชั้นด้านล่าง:

เรามีฐาน GameObject ซึ่งถูกจัดประเภทย่อยด้วย Animal Animal ถูกจัดประเภทย่อยด้วย Bunny และ Whale ซึ่งแต่ละรายการมีลักษณะการทำงานพิเศษของตัวเอง hop() และ swim() ตามลำดับ นอกจากนี้เรายังมี Killer Whale ซึ่งเป็นคลาสย่อยของ Whale ซึ่งมากกว่า kill()

มาลองแนะนำสัตว์ชนิดใหม่ให้กับโลกของเรา:

เราต้องการให้ Killer Bunny สามารถ hop() และ kill() ได้ แต่คลาสใดที่ Killer Bunny ควรสืบทอดมาจาก?

สำหรับภาษา/แพลตฟอร์มที่มีการสืบทอดเพียงรายการเดียว เราโชคไม่ดี เราจะต้องย้ายทั้ง hop() และ kill() ไปยังซูเปอร์คลาสบางตัว เช่น Animal ที่ Killer Bunny สามารถสืบทอดได้ อย่างไรก็ตาม คลาสย่อยอื่นๆ ทั้งหมดของ Animal จะสืบทอดสิ่งที่พวกเขาไม่ต้องการ Whale สืบทอด hop(); Bunny สืบทอด swim() และ kill() เมื่อเวลาผ่านไป Animal จะกลายเป็นวัตถุเทพที่มีพฤติกรรมมากมาย

การสืบทอดหลายรายการก็ไม่ได้ทำเช่นกัน สมมติว่า Killer Bunny สืบทอดมาจากทั้ง Bunny และ Killer Whale Killer Bunny จะสืบทอด swim() ซึ่งเป็นฟังก์ชันที่ไม่จำเป็น

เราเผชิญกับปัญหาอื่นๆ อีกหลายประการ:

ฟังก์ชันการทำงานที่เข้มงวด: มีเพียง Killer Whale เท่านั้นที่สามารถ kill() เราไม่สามารถเปลี่ยนใจในภายหลังและทำให้สัตว์อื่น kill() เป็นเรื่องง่ายมาก พฤติกรรมใช้ได้เฉพาะกับคลาสที่ได้รับการเข้ารหัสโดยเฉพาะเพื่อสนับสนุนพฤติกรรมนั้น เมื่อจำนวนเอนทิตีเกมเพิ่มมากขึ้น เราก็เผชิญกับความยากลำบากมากขึ้นในการหาจุดในลำดับชั้นเพื่อวางเอนทิตีใหม่ไว้ข้างใต้

ปัญหาเพชร: "ปัญหาเพชร" (บางครั้งเรียกว่า "เพชรมรณะ") เป็นความคลุมเครือที่เกิดขึ้นเมื่อคลาส B และ C สองชั้นสืบทอดมาจาก A และคลาส D สืบทอดมาจากทั้งสองคลาส B และ C หากมีวิธีการใน A ที่ B และ C ได้เขียนทับไว้ และ D ไม่ได้แทนที่มัน แล้ว D สืบทอดเวอร์ชันใด: ของ B หรือของ C?

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

สรุป

ปัญหาที่กล่าวมาข้างต้นรบกวนผู้พัฒนาเกมมาเป็นเวลานาน และ Entity Component Systems พยายามที่จะแก้ไขสิ่งรบกวนเหล่านี้ เราจะเรียนรู้เกี่ยวกับ ECS ในหัวข้อถัดไป

ระบบส่วนประกอบเอนทิตี

มีนามธรรมที่สำคัญสามประการใน ECS: เอนทิตี ส่วนประกอบ และระบบ

เราจะตรวจสอบแต่ละรายการโดยละเอียด เริ่มตั้งแต่ Component

ส่วนประกอบ

คุณสมบัติหรือลักษณะของเอนทิตี

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

ลองนึกภาพเรามีเอนทิตี Bunny ในโลกของเรา:

เราสามารถนิยามกระต่ายว่าไม่มีอะไรมากไปกว่าการรวมตัว/การรวบรวมส่วนประกอบอิสระ ในตัวอย่างข้างต้น Bunny จะ 'ประกอบด้วย' ส่วนประกอบต่างๆ เช่น Physical และ Seeing

แต่ละองค์ประกอบสนับสนุนพฤติกรรมบางอย่าง เพื่อแสดงให้เห็น Seeing มีคุณลักษณะ sight_radius เพื่อรองรับพฤติกรรมการมองเห็น อย่างไรก็ตาม โปรดทราบว่า Component เองก็ไม่มีพฤติกรรมใดๆ แต่ละส่วนประกอบเป็นเพียงออบเจ็กต์ข้อมูลขั้นต่ำ

เอนทิตี

การรวมกลุ่มหรือคอนเทนเนอร์ของส่วนประกอบ

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

ลองดูที่ Bunny ของเราอีกครั้ง:

เห็นกล่องประรอบๆ ส่วนประกอบของเราไหม นั่นคือเอนทิตี Bunny - ไม่มีอะไรมากไปกว่าคอนเทนเนอร์ของส่วนประกอบ เราสามารถกำหนดเอนทิตีเป็นการรวมชุดย่อยของส่วนประกอบใดๆ เช่นนี้ Carrot:

และนี่คือ Ghost:

เอนทิตีเป็นมากกว่าการรวบรวมส่วนประกอบเพียงเล็กน้อย

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

จนถึงจุดนี้ เราไม่ได้แตะต้องตรรกะหรือพฤติกรรมใดๆ เลย เอนทิตีเป็นเพียงการรวมส่วนประกอบต่างๆ ส่วนประกอบเป็นเพียงวัตถุข้อมูล พฤติกรรมใน ECS มาจากไหน? พวกเขามาจากระบบ

ระบบ

ระบบทำให้เอนทิตีและส่วนประกอบต่างๆ มีชีวิตขึ้นมา

ระบบจะแจกแจงส่วนประกอบหรือกลุ่มของส่วนประกอบ โดยอัปเดตสถานะตามกฎภายในหรือเหตุการณ์ภายนอก วิธีคิดเกี่ยวกับพฤติกรรมคือการเปลี่ยนแปลงจากสถานะหนึ่งไปอีกสถานะหนึ่ง ลองดูตัวอย่าง:

พฤติกรรม: “กระต่ายบนต้นไม้ตกลงมาเนื่องจากแรงโน้มถ่วง”

เราจะนำพฤติกรรมข้างต้นไปใช้อย่างไร? เราสามารถทำได้เพื่อให้ Placeable Components ที่มีค่า z มากกว่า 0 ลดลงเมื่อเวลาผ่านไปเป็น 0

พฤติกรรม: “อายุของสิ่งมีชีวิต”

เราจะนำพฤติกรรมข้างต้นไปใช้อย่างไร? เราสามารถทำได้เพื่อให้ Living Components มีค่าเพิ่มขึ้น age เมื่อเวลาผ่านไป

เราสร้างระบบเฉพาะสำหรับแต่ละพฤติกรรมที่เราต้องการสนับสนุน A GravitySystem ระบุส่วนประกอบ Placeable ทั้งหมด a TimeSystem ระบุส่วนประกอบ Living ทั้งหมด โปรดทราบว่าระบบดำเนินการกับส่วนประกอบ ไม่ใช่เอนทิตี

การไหลของข้อมูลในเอนทิตี-ส่วนประกอบ-ระบบ

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

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

อีกตัวอย่างหนึ่ง: สมมติว่าผู้เล่นกดปุ่ม "ย้ายไปทางซ้าย" PlayerInputSystem ดำเนินการและตรวจจับการกดปุ่ม โดยอัปเดตองค์ประกอบ Motion MotionSystem ดำเนินการและ "มองเห็น" การเคลื่อนที่ของเอนทิตีที่อยู่ทางซ้าย โดยใช้แรงฟิสิกส์ไปทางซ้าย RenderSystem ดำเนินการและอ่านตำแหน่งปัจจุบันของเอนทิตี และวาดตามคำจำกัดความเชิงพื้นที่ (ซึ่งอาจรวมถึงข้อมูลพื้นที่พื้นผิว / ภาพเคลื่อนไหว)

ความรู้เบื้องต้นเกี่ยวกับระบบเอนทิตี

การเปรียบเทียบสเปรดชีต

วิธีคิด ECS อีกวิธีหนึ่งก็คือเป็นตารางเชิงสัมพันธ์ เช่นเดียวกับสเปรดชีต:

ระบบส่วนประกอบเอนทิตีสามารถแสดงภาพเป็นตารางที่มีคอลัมน์ของส่วนประกอบและแถวของเอนทิตี ในการทำงานกับส่วนประกอบเดียว เราเลือกคอลัมน์และดูที่แต่ละเซลล์ เพื่อดำเนินการกับเอนทิตี เราจะเลือกแถวและดูแต่ละเซลล์

ข้อดีของอีซีเอส

ตอนนี้เรามีความเข้าใจที่ดีขึ้นเกี่ยวกับสถาปัตยกรรม Entity-Component-System แล้ว ลองมาคิดว่าวิธีการนี้เปรียบเทียบกับการสืบทอดตามคลาสอย่างไร

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

ความสามารถในการเรียบเรียงและคำจำกัดความของออบเจ็กต์รันไทม์: คุณสามารถสร้างออบเจ็กต์เกมประเภทใดก็ได้โดยการเพิ่มส่วนประกอบที่ถูกต้องให้กับเอนทิตี นอกจากนี้ยังช่วยให้นักพัฒนาสามารถเพิ่มคุณสมบัติของออบเจ็กต์ประเภทหนึ่งไปยังอีกประเภทหนึ่งได้อย่างง่ายดาย โดยไม่มีปัญหาการพึ่งพาใดๆ ตัวอย่างเช่น เราสามารถทำ Entity.build([FlyingComponent, SeeingComponent]) ขณะรันไทม์ได้

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

สามารถขนานกันได้: ในการใช้งาน ECS ในโลกแห่งความเป็นจริง เช่น MMO ระบบจะถูกนำไปใช้เป็นระบบแบบกระจายหรือกลุ่มผู้ปฏิบัติงานที่สามารถกระจายงานระหว่างกันได้ สิ่งนี้ช่วยให้เราปรับขนาดการจำลองในแนวนอนโดยการเพิ่มจำนวนผู้ปฏิบัติงานระบบในกลุ่มของเรา

การแยกข้อมูลและพฤติกรรม: ส่วนประกอบเก็บข้อมูล ระบบเก็บข้อมูลพฤติกรรม ไม่มีการผสมผสานระหว่างทั้งสอง คุณสมบัตินี้ช่วยให้คุณสามารถเสียบปลั๊กและเล่นพฤติกรรมที่แตกต่างกันเพื่อนำไปใช้กับข้อมูลเดียวกันได้

ความท้าทายของ ECS

แม้ว่าสิ่งนี้จะทำให้เรามีความยืดหยุ่น แต่ ECS ก็นำเสนอความท้าทายที่ไม่สำคัญหลายประการ:

ECS เป็นรูปแบบที่ค่อนข้างไม่เป็นที่รู้จัก: เนื่องจากรูปแบบการออกแบบนี้ส่วนใหญ่จำกัดอยู่ที่การพัฒนาเกม การพูดคุยถึงวิธีใช้ ECS สำหรับโดเมนภายนอก เช่น การสร้างเว็บแอปอาจเป็นเรื่องที่ท้าทาย มีทรัพยากรเพียงเล็กน้อยสำหรับการนำรูปแบบนี้ไปใช้กับโดเมนอื่นๆ ถ้ามี

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

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

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

ไม่ได้กำหนดอย่างเป็นรูปธรรมเท่ากับรูปแบบการออกแบบอื่นๆ เช่น MVC: มีหลายวิธีในการนำ ECS ไปใช้ แต่ละภาษาหรือแพลตฟอร์มจะมีบทคัดย่อที่แตกต่างกัน ซึ่งส่งผลให้ ECS มีรสชาติที่แตกต่างกัน

ECS ในโลกแห่งความเป็นจริง

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

สตาร์ทอัพรายหนึ่งกำลังสร้างบริการ ECS-as-a-a ที่เรียกว่า "SpatialOS"

การใช้งาน ECS ใน Elixir

ในส่วนนี้ เราจะมาดูการใช้งาน Entity-Component-System ที่เป็นไปได้ใน Elixir ฉันจะเริ่มด้วยการพูดถึงสั้นๆ ว่าทำไม Elixir (หรือ Erlang) และการทำงานพร้อมกันแบบดั้งเดิมจึงเหมาะสมกับรูปแบบ ECS

จากนี้ไป เนื่องจาก Elixir คอมไพล์เป็นโค้ดไบต์ Erlang เมื่อฉันพูดว่า Elixir ฉันก็หมายถึง Erlang ด้วย

นักแสดงรุ่น

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

  • ส่งข้อความ
  • รับข้อความ
  • วางไข่นักแสดงหน้าใหม่

ในแผนภาพด้านบน นักแสดง A ส่งข้อความ 1 และ 2 ไปยังนักแสดง C ซึ่งได้รับ เพื่อตอบสนองต่อข้อความเหล่านี้ นักแสดง C สามารถส่งข้อความใหม่ หรือวางไข่นักแสดงใหม่และรอรับข้อความจากนักแสดงเหล่านั้น

# Try running this in an Elixir interpreter (iex)

# We first spawn a new actor that listens for messages, returning a process id (pid)
jeff = spawn(fn ->
        receive do
          {sender, message} -> IO.puts "Received '#{message}' from process #{inspect sender}"
        end
       end)

# We send a message to jeff's pid, adding our own pid in the message
send jeff, {self(), “Hello world”}

# send, receive, and spawn are built-in Elixir primitives

Elixir ยังมีนามธรรมระดับสูงกว่าสำหรับการสร้างนักแสดงที่เรียกว่า GenServers:

defmodule Stack do
  use GenServer

  # Callbacks

  def handle_call(:pop, _from, [h | t]) do
    {:reply, h, t}
  end

  def handle_cast({:push, item}, state) do
    {:noreply, [item | state]}
  end
end

# Start the server
{:ok, pid} = GenServer.start_link(Stack, [:hello])

# This is the client
GenServer.call(pid, :pop)
#=> :hello

GenServer.cast(pid, {:push, :world})
#=> :ok

GenServer.call(pid, :pop)
#=> :world

พิจารณาว่าคุณจะนำ ECS ไปใช้อย่างไรโดยได้รับความช่วยเหลือจากนักแสดง

ตัวอย่างการใช้งาน

ต่อไปนี้คือลักษณะการใช้งานของเราเมื่อใช้งาน:

# Instantiates a new entity with a set of parameterized components
# TimeComponent is a component that counts up
> bunny = ECS.Entity.build([TimeComponent.new(%{age: 0})])

# We trigger the TimeSystem to enumerate over all TimeComponents
# In the real world this could be in response to an event stream such as player input
> TimeSystem.process

# We pull the latest state of the components
> bunny = ECS.Entity.reload(bunny)

# We can repeat this process
> TimeSystem.process
> bunny = ECS.Entity.reload(bunny)

# Modifies an existing entity at runtime by adding a new component to it
bunny = ECS.Entity.add(bunny, TimeComponent.new(%{age: 10}))

# We can repeat this process, and both TimeComponents will receive state updates
TimeSystem.process
bunny = ECS.Entity.reload(bunny)

การใช้งาน ECS ของฉันใน Elixir นั้นเป็นโอเพ่นซอร์สบน Github คุณสามารถโคลนและเรียกใช้ผ่าน iex -S mix จากโฟลเดอร์รูท คุณต้องติดตั้ง Elixir ไว้ในเครื่องของคุณ

การนำไปปฏิบัติ

เอนทิตีคือโครงสร้างที่มีสตริงสุ่ม id และรายการส่วนประกอบ เราสามารถสร้างเอนทิตีและขยายได้โดยการเพิ่มส่วนประกอบ สามารถทำได้ทั้งสองอย่างในขณะรันไทม์

defmodule ECS.Entity do
  @moduledoc """
    A base for creating new Entities.
  """

  defstruct [:id, :components]

  @type id :: String.t
  @type components :: list(ECS.Component)
  @type t :: %ECS.Entity{
    id: String.t,
    components: components
  }

  @doc "Creates a new entity"
  @spec build(components) :: t
  def build(components) do
    %ECS.Entity{
      id: ECS.Crypto.random_string(64),
      components: components
    }
  end

  @doc "Add components at runtime"
  def add(%ECS.Entity{ id: id, components: components}, component) do
    %ECS.Entity{
      id: id,
      components: components ++ [component]
    }
  end

  @doc "Pulls the latest component states"
  @spec reload(t) :: t
  def reload(%ECS.Entity{ id: _id, components: components} = entity) do
    updated_components = components
      |> Enum.map(fn %{id: pid} ->
        ECS.Component.get(pid)
      end)

    %{entity | components: updated_components}
  end
end

ด้านล่างนี้เป็นเอนทิตีจริง Bunny:

# A bunny prefab
defmodule Bunny do
  def new do
    ECS.Entity.build([TimeComponent.new(%{age: 0})])
  end
end

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

ส่วนประกอบ

โมดูล Component และ Component.Agent อำนวยความสะดวกในการรับและตั้งค่าสถานะ แต่ละองค์ประกอบได้รับการสนับสนุนโดยนักแสดง (ตัวแทน - GenServer ชนิดหนึ่ง) ส่วนประกอบเช่น TimeComponent ใช้พฤติกรรม Component (อินเทอร์เฟซ)

ddefmodule ECS.Component do
  @moduledoc """
    A base for creating new Components.
  """

  defstruct [:id, :state]

  @type id :: pid()
  @type component_type :: String.t
  @type state :: map()
  @type params :: map()
  @type t :: %ECS.Component{
    id: id, # Component Agent ID
    state: state
  }

  @callback new(state) :: t # Component interface

  defmacro __using__(_options) do
    quote do
      @behaviour ECS.Component # Require Components to implement interface
    end
  end

  @doc "Create a new agent to keep the state"
  @spec new(component_type, state) :: t
  def new(component_type, initial_state) do
    {:ok, pid} = ECS.Component.Agent.start_link(initial_state)
    ECS.Registry.insert(component_type, pid) # Register component for systems to reference
    %{
      id: pid,
      state: initial_state
    }
  end

  @doc "Retrieves state"
  @spec get(id) :: t
  def get(pid) do
    state = ECS.Component.Agent.get(pid)
    %{
      id: pid,
      state: state
    }
  end

  @doc "Updates state"
  @spec update(id, state) :: t
  def update(pid, new_state) do
    ECS.Component.Agent.set(pid, new_state)
    %{
      id: pid,
      state: new_state
    }
  end
end
defmodule ECS.Component.Agent do
  @moduledoc """
    Create a simple Agent that gets and sets.
    Each component instantiates one to keep state.
  """

  @doc "Starts a new bucket. Returns {:status, pid}"
  def start_link(initial_state \\ %{}, opts \\ []) do
    Agent.start_link((fn -> initial_state end), opts)
  end

  @doc "Gets entire state from pid"
  def get(pid) do
    Agent.get(pid, &(&1))
  end

  @doc "Gets a value from the `pid` by `key`"
  def get(pid, key) do
    Agent.get(pid, &Map.get(&1, key))
  end

  @doc "Overwrites state with new_state."
  def set(pid, new_state) do
    Agent.update(pid, &Map.merge(&1, new_state))
  end

  @doc "Updates the `value` for the given `key` in the `pid`"
  def set(pid, key, value) do
    Agent.update(pid, &Map.put(&1, key, value))
  end
end

ด้านล่างนี้เป็นองค์ประกอบจริง TimeComponent ซึ่งใช้พฤติกรรม Component:

defmodule TimeComponent do
  @moduledoc """
    A component for keeping an age for something.
    {id: pid, state: state} = TimeComponent.new(%{age: 1})
  """
  use ECS.Component

  @component_type __MODULE__

  @doc "Initializes and validates state"
  def new(%{age: _age} = initial_state) do
    ECS.Component.new(@component_type, initial_state)
  end
end

ระบบและรีจิสทรี

ระบบจะแจกแจงส่วนประกอบทุกประเภท

defmodule TimeSystem do
  @moduledoc """
    Increments ages of TimeComponents
  """

  def process do
    components()
      |> Enum.each(fn (pid) -> dispatch(pid, :increment) end)
  end

  # dispatch() is a pure reducer that takes in a state and an action and returns a new state
  defp dispatch(pid, action) do
    %{id: _pid, state: state} = ECS.Component.get(pid)

    new_state = case action do
      :increment ->
        Map.put(state, :age, state.age + 1)
      :decrement ->
        Map.put(state, :age, state.age - 1)
      _ ->
        state
    end

    IO.puts("Updated #{inspect pid} to #{inspect new_state}")
    ECS.Component.update(pid, new_state)
  end

  defp components do
    ECS.Registry.get(:"Elixir.TimeComponent")
  end
end

บางสิ่งที่ควรทราบ:

  • dispatch ดำเนินการภายนอก ประเมินตามกฎภายใน และส่งคืนสถานะใหม่ ส่วนนี้ได้รับแรงบันดาลใจส่วนใหญ่จากประสบการณ์ของฉันกับตัวลด Redux และ Elm's update
  • เมธอด components ส่งคืนชุดส่วนประกอบที่ระบบนี้จะระบุ เมื่อใดก็ตามที่ส่วนประกอบถูกสร้างอินสแตนซ์ ส่วนประกอบจะลงทะเบียนเอเจนต์ไปที่ Registry ซึ่งจะติดตามส่วนประกอบที่ใช้งานอยู่ทั้งหมด Registry เองก็เป็นนักแสดงดังที่แสดงด้านล่าง:
defmodule ECS.Registry do
  @moduledoc """
    Component registry.
    iex> {:ok, r} = ECS.Registry.start
    iex> ECS.Registry.insert("test", r)
    :ok
    iex> ECS.Registry.get("test")
    [#PID<0.87.0>]
  """

  def start do
    Agent.start_link(fn -> %{} end, name: __MODULE__)
  end

  def insert(component_type, component_pid) do
    Agent.update(__MODULE__, fn(registry) ->
      components = (Map.get(registry, component_type, []) ++ [component_pid])
      Map.put(registry, component_type, components)
    end)
  end

  def get(component_type) do
    Agent.get(__MODULE__, fn(registry) ->
      Map.get(registry, component_type, [])
    end)
  end
end

และนั่นมัน!

การนำ ECS ไปใช้นี้อาจมีความหยาบเล็กน้อยในส่วน Edge ECS มีหลากหลายรูปแบบ และนี่ไม่ใช่วิธีเดียวที่จะทำให้ ECS ทำงานได้อย่างแน่นอน ยินดีรับฟังความคิดเห็นของคุณ!

ในการปิด

ECS เป็นรูปแบบสถาปัตยกรรมที่ถูกมองข้าม ซึ่งเอาชนะข้อบกพร่องบางประการของการสืบทอดแบบ OOP และเหมาะอย่างยิ่งสำหรับระบบแบบกระจาย

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

ขอบคุณที่อ่าน! ฉันหวังว่าคุณจะพบว่าบทความนี้มีประโยชน์หรือน่าสนใจ แจ้งให้เราทราบความคิดของคุณผ่านความคิดเห็นด้านล่าง!

เผยแพร่ครั้งแรกที่ yos.io เมื่อวันที่ 17 กันยายน 2016