คำถามเกี่ยวกับสถาปัตยกรรม / การออกแบบเกม - การสร้างเอนจิ้นที่มีประสิทธิภาพขณะเดียวกันก็หลีกเลี่ยงอินสแตนซ์ระดับโลก (เกม C ++)


28

ฉันมีคำถามเกี่ยวกับสถาปัตยกรรมเกม: วิธีที่ดีที่สุดในการสื่อสารด้วยส่วนประกอบต่าง ๆ กันคืออะไร?

ฉันขอโทษจริง ๆ ถ้าคำถามนี้ถูกถามไปแล้วนับล้านครั้ง แต่ฉันไม่สามารถหาข้อมูลใด ๆ ที่ฉันต้องการได้

ฉันพยายามสร้างเกมตั้งแต่เริ่มต้น (C ++ ถ้าเป็นเรื่องสำคัญ) และได้สังเกตซอฟต์แวร์เกมโอเพ่นซอร์สเพื่อเป็นแรงบันดาลใจ (Super Maryo Chronicles, OpenTTD และอื่น ๆ ) ฉันสังเกตเห็นว่าการออกแบบเกมเหล่านี้จำนวนมากใช้อินสแตนซ์ระดับโลกและ / หรือซิงเกิลตันทั่วสถานที่ (สำหรับสิ่งต่าง ๆ เช่นการเรนเดอร์คิวผู้จัดการเอนทิตีผู้จัดการวิดีโอและอื่น ๆ ) ฉันพยายามหลีกเลี่ยงอินสแตนซ์ระดับโลกและซิงเกิลตันและสร้างเครื่องยนต์ที่มีการเชื่อมโยงอย่างหลวม ๆ เท่าที่จะเป็นไปได้ แต่ฉันตีสิ่งกีดขวางบางอย่าง (ส่วนหนึ่งของแรงจูงใจสำหรับโครงการนี้คือการแก้ไขปัญหานี้ :))

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

ยกตัวอย่างเช่นเมื่อมองจาก Super Maryo Chronicles เมื่อใดก็ตามที่ส่วนประกอบของเกมต้องการสื่อสารกับส่วนประกอบอื่น (เช่นวัตถุศัตรูต้องการเพิ่มตัวเองลงในคิวการเรนเดอร์ที่จะดึงออกมาในช่วงเรนเดอร์) อินสแตนซ์ระดับโลก

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

  1. อินสแตนซ์ส่วนกลาง (การออกแบบของ Super Maryo Chronicles, OpenTTD และอื่น ๆ )
  2. มีGameCoreวัตถุที่ทำหน้าที่เป็นคนกลางที่วัตถุทั้งหมดสื่อสาร (การออกแบบปัจจุบันอธิบายไว้ข้างต้น)
  3. มอบพอยน์เตอร์ให้กับส่วนประกอบอื่น ๆ ทั้งหมดที่พวกเขาจะต้องพูดคุยด้วย (เช่นในตัวอย่าง Maryo ด้านบนคลาสศัตรูจะมีตัวชี้ไปยังวัตถุวิดีโอที่จำเป็นต้องพูดคุยด้วย)
  4. แบ่งเกมออกเป็นระบบย่อย - ตัวอย่างเช่นมีวัตถุผู้จัดการในGameCoreวัตถุที่จัดการการสื่อสารระหว่างวัตถุในระบบย่อยของพวกเขา
  5. (ตัวเลือกอื่น? ....)

ฉันจินตนาการถึงตัวเลือก 4 ด้านบนเพื่อเป็นทางออกที่ดีที่สุด แต่ฉันมีปัญหาในการออกแบบ ... อาจเป็นเพราะฉันคิดในแง่ของการออกแบบที่ฉันเคยเห็นว่าใช้ก้อนกลม ฉันรู้สึกว่าฉันกำลังประสบปัญหาเดียวกันกับที่มีอยู่ในการออกแบบปัจจุบันของฉันและจำลองมันในแต่ละระบบย่อยเพียงในระดับที่เล็กลง ตัวอย่างเช่นGameStageวัตถุที่อธิบายไว้ข้างต้นนั้นค่อนข้างพยายาม แต่GameCoreวัตถุนั้นยังคงมีส่วนร่วมในกระบวนการ

ใครช่วยเสนอคำแนะนำการออกแบบที่นี่ได้ไหม

ขอบคุณ!


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

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

ฉันเปลี่ยนแท็กเนื่องจากคำถามนี้เกี่ยวกับรหัสไม่ใช่การออกแบบเกม
Klaim

กระทู้นี้ค่อนข้างเก่า แต่ฉันมีปัญหาเดียวกัน ฉันใช้ OGRE และพยายามใช้วิธีที่ดีที่สุดในตัวเลือกความคิดเห็น # 4 เป็นวิธีที่ดีที่สุด ฉันได้สร้างบางสิ่งเช่น Advanced Ogre Framework แต่นี่ไม่ได้เป็นแบบแยกส่วน ฉันคิดว่าฉันต้องการระบบจัดการอินพุตย่อยซึ่งจะได้รับความนิยมของแป้นพิมพ์และการเคลื่อนไหวของเมาส์เท่านั้น สิ่งที่ฉันไม่เข้าใจคือฉันจะสร้างตัวจัดการ "การสื่อสาร" ระหว่างระบบย่อยได้อย่างไร
Dominik2000

1
สวัสดี @ Dominik2000 นี่เป็นเว็บไซต์ถาม - ตอบไม่ใช่ฟอรัม หากคุณมีคำถามคุณควรโพสต์คำถามจริงไม่ใช่คำตอบของคำถามที่มีอยู่ ดูคำถามที่พบบ่อยสำหรับรายละเอียดเพิ่มเติม
Josh

คำตอบ:


19

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

เช่น (รหัส C # ที่สามารถแปลเป็น C ++ หรือ Java ได้อย่างง่ายดาย)

ให้บอกว่าคุณมีอินเทอร์เฟซแบ็กเอนด์เรนเดอร์ซึ่งมีการดำเนินงานทั่วไปบางอย่างสำหรับการเรนเดอร์เนื้อหา

public interface IRenderBackend
{
    void Draw();
}

และคุณมีการใช้งานการเรนเดอร์เรนเดอร์ที่เป็นค่าเริ่มต้น

public class DefaultRenderBackend : IRenderBackend
{
    public void Draw()
    {
        //do default rendering stuff.
    }
}

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

นี่คือวิธี:

public class ServiceLocator<T>
{
    private static T currGlobalInstance;

    public static T Service
    {
        get { return currGlobalInstance; }
        set { currGlobalInstance = value; }
    }
}

เพื่อให้สามารถเข้าถึงวัตถุส่วนกลางของคุณคุณต้องทำให้เป็นจริงก่อน

//somewhere during program initialization
ServiceLocator<IRenderBackend>.Service = new DefaultRenderBackend();

//somewhere else in the code
IRenderBackend currentRenderBackend = ServiceLocator<IRenderBackend>.Service;

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

public class IsometricRenderBackend : IRenderBackend
{
    void draw()
    {
        //do rendering using an isometric view
    }
}

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

ServiceLocator<IRenderBackend>.Service = new IsometricRenderBackend();

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

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


ฉันเคยตรวจสอบเรื่องนี้มาก่อน แต่ไม่ได้นำไปใช้จริงกับการออกแบบของฉัน คราวนี้ฉันวางแผนที่จะ ขอบคุณ :)
Awesomania

3
@Erevis ดังนั้นโดยทั่วไปคุณกำลังอธิบายการอ้างอิงทั่วโลกไปยังวัตถุ polymorphic ในทางกลับกันนี่เป็นเพียงการอ้อมสองทาง (ตัวชี้ -> อินเตอร์เฟส -> การนำไปใช้) ใน C ++ std::unique_ptr<ISomeService>มันอาจจะดำเนินการได้อย่างง่ายดายเช่น
Shadows In Rain

1
คุณสามารถเปลี่ยนกลยุทธ์การเริ่มต้นเป็น "เริ่มต้นในการเข้าถึงครั้งแรก" และหลีกเลี่ยงการจำเป็นต้องมีลำดับรหัสภายนอกบางอย่างจัดสรรและผลักดันบริการไปยังที่ตั้ง คุณสามารถเพิ่มรายการ "ขึ้นอยู่กับ" ในบริการเพื่อให้เมื่อมีการเริ่มต้นบริการจะตั้งค่าบริการอื่น ๆ โดยอัตโนมัติโดยไม่จำเป็นต้องสวดมนต์และมีคนจำได้ว่าทำเช่นนั้นใน main.cpp คำตอบที่ดีพร้อมความยืดหยุ่นสำหรับการปรับเปลี่ยนในอนาคต
Patrick Hughes

4

มีหลายวิธีในการออกแบบเอ็นจิ้นเกมและจริงๆแล้วมันน่าสนใจมาก

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

ฉันเชื่อว่าคุณอยู่ในเส้นทางที่ถูกต้องพร้อมกับคิดถึงตัวเลือก # 4

โปรดจำไว้เมื่อมันมาถึงการสื่อสารตัวเองที่ไม่จำเป็นต้องหมายถึงฟังก์ชั่นโดยตรงเรียกตัวเองเสมอ มีวิธีการหลายทางอ้อมการสื่อสารสามารถเกิดขึ้นไม่ว่าจะด้วยวิธีการทางอ้อมโดยใช้หรือใช้Signal and SlotsMessages

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

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


1

คุณเพียงแค่ต้องทำให้แน่ใจว่าไม่มีการพึ่งพาย้อนกลับหรือวัฏจักร ตัวอย่างเช่นถ้าคุณมีคลาสCoreและสิ่งนี้CoreมีLevelและและLevelมีรายการจากEntityนั้นแผนภูมิการพึ่งพาควรมีลักษณะดังนี้:

Core --> Level --> Entity

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

พิจารณารหัสต่อไปนี้ (C ++):

class Core;
class Entity;
class Level;

class Level
{
    public:
        Level(Core& coreIn) : core(coreIn) {}

        Core& core;
}

class Entity
{
    public:
        Entity(Level& levelIn) : level(levelIn) {}

        Level& level;
}

ใช้เทคนิคนี้คุณจะเห็นว่าแต่ละคนEntityมีการเข้าถึงLevelและมีการเข้าถึงLevel Coreขอให้สังเกตว่าแต่ละEntityร้านอ้างอิงถึงLevelหน่วยความจำที่สูญเปล่า เมื่อสังเกตเห็นนี้คุณควรจะตั้งคำถามหรือไม่ว่าแต่ละคนต้องเข้าถึงEntityLevel

จากประสบการณ์ของฉันมีA)วิธีที่ชัดเจนจริงๆในการหลีกเลี่ยงการพึ่งพาย้อนกลับหรือB)ไม่มีวิธีที่เป็นไปได้ที่จะหลีกเลี่ยงอินสแตนซ์ระดับโลกและซิงเกิลตัน


ฉันพลาดอะไรไปรึเปล่า? คุณพูดถึง 'คุณไม่ควรมีเอนทิตีขึ้นอยู่กับระดับ' แต่คุณต้องอธิบายว่ามันคือ ctor ในฐานะ 'เอนทิตี (ระดับ & ระดับใน)' ฉันเข้าใจว่าการอ้างอิงถูกส่งผ่านโดยการอ้างอิง แต่ยังคงเป็นการพึ่งพา
Adam Naylor

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

0

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

class ISomeComponent // abstract base class
{
    //...
};

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;

0

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

struct sEnvironment
{
    owning<iAudio*> m_Audio;
    owning<iRenderer*> m_Renderer;
    owning<iGameLevel*> m_GameLevel;
    ...
}

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

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.