Câu hỏi về thiết kế / kiến ​​trúc trò chơi - xây dựng một công cụ hiệu quả trong khi tránh các trường hợp toàn cầu (trò chơi C ++)


28

Tôi đã có một câu hỏi về kiến ​​trúc trò chơi: cách tốt nhất để các thành phần khác nhau giao tiếp với nhau là gì?

Tôi thực sự xin lỗi nếu câu hỏi này đã được hỏi hàng triệu lần, nhưng tôi không thể tìm thấy bất cứ điều gì với chính xác loại thông tin mà tôi đang tìm kiếm.

Tôi đã cố gắng xây dựng một trò chơi từ đầu (C ++ nếu có vấn đề) và đã quan sát một số phần mềm trò chơi nguồn mở để lấy cảm hứng (Super Maryo Chronicles, OpenTTD và các phần mềm khác). Tôi nhận thấy rằng rất nhiều thiết kế trò chơi này sử dụng các phiên bản toàn cầu và / hoặc singletons ở khắp mọi nơi (đối với những thứ như kết xuất hàng đợi, quản lý thực thể, quản lý video, v.v.). Tôi đang cố gắng tránh các trường hợp và singletons toàn cầu và xây dựng một công cụ kết hợp lỏng lẻo nhất có thể, nhưng tôi gặp phải một số trở ngại do thiếu kinh nghiệm trong thiết kế hiệu quả. (Một phần động lực cho dự án này là để giải quyết vấn đề này :))

Tôi đã xây dựng một thiết kế trong đó tôi có một GameCoređối tượng chính có các thành viên tương tự như các trường hợp toàn cầu mà tôi thấy trong các dự án khác (nghĩa là nó có trình quản lý đầu vào, trình quản lý video, GameStageđối tượng điều khiển tất cả các thực thể và trò chơi cho bất kỳ giai đoạn nào hiện đang được tải, vv). Vấn đề là vì mọi thứ đều tập trung trong GameCoređối tượng, tôi không có cách dễ dàng để các thành phần khác nhau giao tiếp với nhau.

Ví dụ, nhìn vào Super Maryo Chronicles, bất cứ khi nào một thành phần của trò chơi cần liên lạc với một thành phần khác (nghĩa là, một đối tượng kẻ thù muốn tự thêm vào hàng đợi kết xuất sẽ được vẽ trong giai đoạn kết xuất), nó chỉ nói chuyện với cá thể toàn cầu.

Đối với tôi, tôi phải yêu cầu các đối tượng trò chơi của mình chuyển thông tin liên quan trở lại GameCoređối tượng để GameCoređối tượng có thể truyền thông tin đó cho (các) thành phần khác của hệ thống cần nó (ví dụ: đối với tình huống trên, mỗi đối tượng kẻ thù sẽ chuyển thông tin kết xuất của họ trở lại GameStageđối tượng, sẽ thu thập tất cả và chuyển lại cho thông tin GameCoređó, lần lượt chuyển thông tin đó đến trình quản lý video để kết xuất). Cảm giác này giống như một thiết kế thực sự khủng khiếp, và tôi đã cố gắng nghĩ đến một giải pháp cho vấn đề này. Suy nghĩ của tôi về các thiết kế có thể:

  1. Các phiên bản toàn cầu (thiết kế Biên niên sử Super Maryo, OpenTTD, v.v.)
  2. GameCoređối tượng hoạt động như một người trung gian thông qua đó tất cả các đối tượng giao tiếp (thiết kế hiện tại được mô tả ở trên)
  3. Đưa các con trỏ thành phần cho tất cả các thành phần khác mà chúng sẽ cần nói chuyện (ví dụ, trong ví dụ Maryo ở trên, lớp kẻ thù sẽ có một con trỏ tới đối tượng video mà nó cần nói chuyện)
  4. Chia trò chơi thành các hệ thống con - Ví dụ: có các đối tượng quản lý trong GameCoređối tượng xử lý giao tiếp giữa các đối tượng trong hệ thống con của chúng
  5. (Sự lựa chọn khác? ....)

Tôi tưởng tượng tùy chọn 4 ở trên là giải pháp tốt nhất, nhưng tôi gặp một số khó khăn khi thiết kế nó ... có lẽ bởi vì tôi đã suy nghĩ về các thiết kế mà tôi đã thấy rằng sử dụng toàn cầu. Cảm giác như tôi đang gặp vấn đề tương tự tồn tại trong thiết kế hiện tại của tôi và sao chép nó trong mỗi hệ thống con, chỉ ở quy mô nhỏ hơn. Ví dụ, GameStageđối tượng được mô tả ở trên là một phần của nỗ lực này, nhưng GameCoređối tượng vẫn tham gia vào quá trình.

Bất cứ ai có thể cung cấp bất kỳ lời khuyên thiết kế ở đây?

Cảm ơn!


1
Tôi hiểu bản năng của bạn rằng singletons không phải là thiết kế tuyệt vời. Theo kinh nghiệm của tôi, chúng là cách đơn giản nhất để quản lý giao tiếp giữa các hệ thống
Emmett Butler

4
Thêm vào như một nhận xét vì tôi không biết nếu đó là một thực tiễn tốt nhất. Tôi có một GameManager trung tâm bao gồm các hệ thống con như InputSystem, GraphicsSystem, v.v ... Mỗi hệ thống con lấy GameManager làm tham số trong hàm tạo và lưu trữ tham chiếu đến một thành viên riêng của lớp. Tại thời điểm đó, tôi có thể tham khảo bất kỳ hệ thống nào khác bằng cách truy cập nó thông qua tham chiếu GameManager.
Inisheer

Tôi đã thay đổi các thẻ vì câu hỏi này là về mã, không phải thiết kế trò chơi.
Klaim

Chủ đề này hơi cũ, nhưng tôi có cùng một vấn đề. Tôi sử dụng OGRE và tôi cố gắng sử dụng cách tốt nhất, theo ý kiến ​​của tôi, tùy chọn số 4 là cách tiếp cận tốt nhất. Tôi đã xây dựng một cái gì đó giống như Advanced Ogre Framework, nhưng đây không phải là mô-đun. Tôi nghĩ rằng tôi cần một xử lý đầu vào hệ thống con chỉ nhận các lần nhấn bàn phím và di chuyển chuột. Những gì tôi không hiểu là, làm thế nào tôi có thể tạo một trình quản lý "giao tiếp" như vậy giữa các hệ thống con?
Dominik2000

1
Xin chào @ Dominik2000, đây là trang web Hỏi & Đáp, không phải diễn đàn. Nếu bạn có một câu hỏi, bạn nên đăng một câu hỏi thực tế, và không phải là một câu trả lời cho một câu hỏi hiện có. Xem faq để biết thêm chi tiết.
Josh

Câu trả lời:


19

Một cái gì đó chúng tôi sử dụng trong các trò chơi của mình để tổ chức dữ liệu toàn cầu của mình là mẫu thiết kế ServiceLocator . Ưu điểm của mẫu này so với mẫu Singleton là việc triển khai dữ liệu toàn cầu của bạn có thể thay đổi trong thời gian chạy ứng dụng. Ngoài ra, các đối tượng toàn cầu của bạn cũng có thể được thay đổi trong thời gian chạy. Một lợi thế khác là việc quản lý thứ tự khởi tạo các đối tượng toàn cầu của bạn dễ dàng hơn, điều này rất quan trọng, đặc biệt là trong C ++.

ví dụ: (mã C # có thể dễ dàng dịch sang C ++ hoặc Java)

Hãy nói rằng bạn có một giao diện phụ trợ kết xuất có một số thao tác phổ biến để kết xuất nội dung.

public interface IRenderBackend
{
    void Draw();
}

Và bạn có cài đặt phụ trợ kết xuất mặc định

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

Trong một số thiết kế có vẻ hợp pháp để có thể truy cập vào phụ trợ kết xuất trên toàn cầu. Trong mẫu Singleton , điều đó có nghĩa là mỗi triển khai IRenderBackend phải được triển khai dưới dạng cá thể toàn cầu duy nhất. Nhưng sử dụng mẫu ServiceLocator không yêu cầu điều này.

Đây là cách thực hiện:

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

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

Để có thể truy cập đối tượng toàn cầu của bạn, bạn cần phải xác định trước nó.

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

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

Chỉ để chứng minh cách triển khai có thể thay đổi trong thời gian chạy, giả sử rằng trò chơi của bạn có một minigame trong đó kết xuất là đẳng cự và bạn triển khai IsometricRenderBackend .

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

Khi bạn chuyển từ trạng thái hiện tại sang trạng thái minigame, bạn chỉ cần thay đổi phụ trợ kết xuất toàn cầu được cung cấp bởi trình định vị dịch vụ.

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

Một lợi thế khác là bạn cũng có thể sử dụng các dịch vụ null. Ví dụ: nếu chúng tôi có dịch vụ ISoundManager và người dùng muốn tắt âm thanh, chúng tôi có thể thực hiện một NullSoundManager không làm gì khi các phương thức của nó được gọi, vì vậy bằng cách đặt đối tượng dịch vụ của ServiceLocator thành đối tượng NullSoundManager chúng tôi có thể đạt được kết quả này với hầu như không có khối lượng công việc.

Tóm lại, đôi khi có thể không thể loại bỏ dữ liệu toàn cầu nhưng điều đó không có nghĩa là bạn không thể tổ chức chúng đúng cách và theo cách hướng đối tượng.


Tôi đã xem xét điều này trước đây nhưng chưa thực sự triển khai nó vào bất kỳ thiết kế nào của tôi. Lần này, tôi dự định. Cảm ơn bạn :)
Awesomania

3
@Erevis Vì vậy, về cơ bản, bạn đang mô tả tham chiếu toàn cầu đến đối tượng đa hình. Đổi lại, đây chỉ là đôi hướng (con trỏ -> giao diện -> thực hiện). Trong C ++, nó có thể được thực hiện dễ dàng như std::unique_ptr<ISomeService>.
Bóng tối trong mưa

1
Bạn có thể thay đổi chiến lược khởi tạo thành "khởi tạo trong lần truy cập đầu tiên" và tránh cần phải có một số chuỗi mã bên ngoài phân bổ và đẩy các dịch vụ đến trình định vị. Bạn có thể thêm danh sách "phụ thuộc" vào các dịch vụ để khi được khởi tạo, nó sẽ tự động thiết lập các dịch vụ khác mà nó cần và không cầu nguyện rằng ai đó sẽ nhớ làm điều đó trong main.cpp. Một câu trả lời tốt với sự linh hoạt cho điều chỉnh trong tương lai.
Patrick Hughes

4

Có nhiều cách để thiết kế một công cụ trò chơi và nó thực sự hoàn toàn phù hợp với sở thích.

Để giải quyết các vấn đề cơ bản, một số nhà phát triển thích thiết kế nó giống như một kim tự tháp trong đó có một lớp lõi hàng đầu thường được gọi là lớp nhân, lõi hoặc lớp khung tạo, sở hữu và khởi tạo một loạt các hệ thống con như vậy như âm thanh, đồ họa, mạng, vật lý, AI, và nhiệm vụ, thực thể và quản lý tài nguyên. Nói chung, các hệ thống con này được tiếp xúc với bạn bởi lớp khung này và thông thường bạn sẽ chuyển lớp khung này cho các lớp của riêng bạn như là một đối số của hàm tạo khi thích hợp.

Tôi tin rằng bạn đang đi đúng hướng với suy nghĩ của bạn về lựa chọn số 4.

Hãy ghi nhớ khi nói về giao tiếp, điều đó không phải lúc nào cũng phải ám chỉ một chức năng gọi trực tiếp. Có nhiều cách giao tiếp gián tiếp có thể xảy ra, cho dù đó là thông qua một số phương pháp gián tiếp sử dụng Signal and Slotshoặc sử dụng Messages.

Đôi khi trong các trò chơi, điều quan trọng là cho phép các hành động xảy ra không đồng bộ để giữ cho vòng lặp trò chơi của chúng tôi di chuyển nhanh nhất có thể để tốc độ khung hình được truyền bằng mắt thường. Người chơi không thích những cảnh quay chậm và nhảm nhí và vì vậy chúng tôi phải tìm cách để mọi thứ trôi chảy cho họ nhưng vẫn giữ logic trôi chảy nhưng trong kiểm tra và ra lệnh cũng vậy. Mặc dù các hoạt động không đồng bộ có vị trí của chúng, chúng cũng không phải là câu trả lời cho mọi hoạt động.

Chỉ cần biết rằng bạn sẽ có một kết hợp của cả giao tiếp đồng bộ và không đồng bộ. Chọn những gì phù hợp, nhưng biết rằng bạn sẽ cần hỗ trợ cả hai kiểu trong số các hệ thống con của bạn. Thiết kế hỗ trợ cho cả hai sẽ phục vụ bạn tốt trong tương lai.


1

Bạn chỉ cần đảm bảo rằng không có phụ thuộc ngược hoặc theo chu kỳ. Ví dụ: nếu bạn có một lớp Corevà cái này Corecó một Level, và Levelcó một danh sách Entity, thì cây phụ thuộc sẽ trông như sau:

Core --> Level --> Entity

Vì vậy, với cây phụ thuộc ban đầu này, bạn nên không bao giờ Entityphụ thuộc vào Levelhoặc Core, và Levelkhông bao giờ nên phụ thuộc vào Core. Nếu một trong hai Levelhoặc Entitycần có quyền truy cập vào dữ liệu cao hơn trong cây phụ thuộc, thì nó sẽ được truyền dưới dạng tham số theo tham chiếu.

Hãy xem xét đoạn mã sau (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;
}

Sử dụng kỹ thuật này, bạn có thể thấy rằng mỗi người Entitycó quyền truy cập vào LevelLevelcó quyền truy cập vào Core. Lưu ý rằng mỗi Entitylưu trữ một tham chiếu đến cùng Level, lãng phí bộ nhớ. Khi nhận thấy điều này, bạn nên đặt câu hỏi liệu mỗi người có Entitythực sự cần quyền truy cập vào hay không Level.

Theo kinh nghiệm của tôi, có A) Một giải pháp thực sự rõ ràng để tránh phụ thuộc ngược lại, hoặc B) Không có cách nào có thể để tránh các trường hợp và singletons toàn cầu.


Tui bỏ lỡ điều gì vậy? Bạn đề cập đến 'bạn không bao giờ nên có Thực thể phụ thuộc vào Cấp độ' nhưng sau đó bạn mô tả ctor đó là 'Thực thể (Cấp độ & cấp độ)'. Tôi hiểu rằng sự phụ thuộc được thông qua bởi ref nhưng nó vẫn là một sự phụ thuộc.
Adam Naylor

@AdamNaylor Vấn đề là đôi khi bạn thực sự cần sự phụ thuộc ngược và bạn có thể tránh được toàn cầu bằng cách chuyển các tài liệu tham khảo. Tuy nhiên, nói chung, tốt nhất là tránh hoàn toàn các phụ thuộc này và không phải lúc nào cũng rõ ràng làm thế nào để làm điều này.
Táo

0

Vì vậy, về cơ bản, bạn muốn tránh trạng thái đột biến toàn cầu ? Bạn có thể biến nó thành cục bộ, bất biến hoặc không phải là trạng thái nào cả. Latter là hiệu quả nhất và linh hoạt, imo. Nó được gọi là ẩn thực hiện.

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

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

0

Câu hỏi thực sự có vẻ là về cách giảm khớp nối mà không làm giảm hiệu suất. Tất cả các đối tượng toàn cầu (dịch vụ) thường tạo thành một loại bối cảnh có thể thay đổi trong thời gian chạy trò chơi. Theo nghĩa này, mẫu định vị dịch vụ phân tán các phần khác nhau của bối cảnh thành các phần khác nhau của ứng dụng, có thể hoặc không thể là những gì bạn muốn. Một cách tiếp cận thế giới thực khác sẽ là tuyên bố một cấu trúc như thế này:

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

Và vượt qua nó như một con trỏ thô không sở hữu sEnvironment*. Ở đây con trỏ trỏ đến các giao diện để khớp nối được giảm theo cách tương tự so với bộ định vị dịch vụ. Tuy nhiên, tất cả các dịch vụ đều ở một nơi (có thể có hoặc không tốt). Đây chỉ là một cách tiếp cận khác.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.