Come evitare le dipendenze circolari tra Player e World?


60

Sto lavorando a un gioco 2D in cui puoi spostarti su, giù, a sinistra ea destra. Ho essenzialmente due oggetti logici di gioco:

  • Giocatore: ha una posizione rispetto al mondo
  • Mondo: disegna la mappa e il giocatore

Finora, World dipende dal giocatore (cioè ha un riferimento ad esso), che ha bisogno della sua posizione per capire dove disegnare il personaggio del giocatore e quale parte della mappa disegnare.

Ora voglio aggiungere il rilevamento delle collisioni per rendere impossibile per il giocatore muoversi attraverso i muri.

Il modo più semplice a cui riesco a pensare è di chiedere al giocatore di chiedere al mondo se il movimento previsto è possibile. Ma ciò introdurrebbe una dipendenza circolare tra Player e World (ovvero ognuno contiene un riferimento all'altro), che sembra degno di essere evitato. L'unico modo in cui mi è venuto in mente è di far muovere il Giocatore dal Mondo , ma lo trovo in qualche modo poco intuitivo.

Qual è la mia migliore opzione? Oppure non vale la pena evitare una dipendenza circolare?


4
Perché pensi che una dipendenza circolare sia una cosa negativa? stackoverflow.com/questions/1897537/...
Fuhrmanator

@Fuhrmanator Non penso che siano generalmente cattivi, ma dovrei rendere le cose un po 'più complesse nel mio codice per introdurne uno.
futlib,

Ho matto un post sulla nostra piccola discussione, niente di nuovo però: yannbane.com/2012/11/… ...
jcora

Risposte:


61

Il mondo non dovrebbe disegnare se stesso; il Renderer dovrebbe disegnare il mondo. Il giocatore non deve disegnare se stesso; il Renderer deve pescare il Giocatore rispetto al Mondo.

Il giocatore dovrebbe chiedere al mondo il rilevamento delle collisioni; o forse le collisioni dovrebbero essere gestite da una classe separata che verificherebbe il rilevamento delle collisioni non solo contro il mondo statico ma anche contro altri attori.

Penso che il mondo probabilmente non dovrebbe essere affatto a conoscenza del giocatore; dovrebbe essere una primitiva di basso livello, non un oggetto divino. Il giocatore dovrà probabilmente invocare alcuni metodi del mondo, forse indirettamente (rilevamento delle collisioni o controllo di oggetti interattivi, ecc.).


25
@ snake5 - C'è una differenza tra "can" e "should". Qualsiasi cosa può disegnare qualsiasi cosa, ma quando è necessario modificare il codice che si occupa del disegno, è molto più semplice passare alla classe "Renderer" piuttosto che cercare "Qualsiasi cosa" che sta disegnando. "ossessione per compartimentazione" è un'altra parola per "coesione".
Nate,

16
@ Mr.Beast, no, non lo è. Sta sostenendo un buon design. Non ha senso stipare tutto in un errore di una classe.
jcora,

23
Whoa, non pensavo che avrebbe scatenato una simile reazione :) Non ho nulla da aggiungere alla risposta, ma posso spiegare perché l'ho dato, perché penso che sia più semplice. Non "corretto" o "corretto". Non volevo che suonasse così. Per me è più semplice perché se mi trovo ad affrontare le classi con troppe responsabilità, una divisione è più veloce della forzatura della lettura del codice esistente. Mi piace il codice in blocchi che posso capire e refactoring in reazione a problemi come quello che sta vivendo @futlib.
Liosan,

12
@ snake5 Dire che l'aggiunta di più classi aggiunge un sovraccarico per il programmatore è spesso completamente sbagliato nella mia esperienza. A mio avviso, le classi di linee 10x100 con nomi informativi e responsabilità ben definite sono più facili da leggere e meno generali per il programmatore rispetto a una singola classe di dio da 1000 linee.
Martin,

7
Come nota su ciò che disegna cosa, Rendererè necessario un qualche tipo, ma ciò non significa che la logica per come viene reso ogni cosa è gestita da Renderer, ogni cosa che deve essere disegnata dovrebbe probabilmente ereditare da un'interfaccia comune come IDrawableo IRenderable(o interfaccia equivalente in qualunque lingua tu stia usando). Il mondo potrebbe essere il Renderer, suppongo, ma sembra che avrebbe superato le sue responsabilità, soprattutto se fosse già un IRenderablesé.
zzzzBov,

35

Ecco come un tipico motore di rendering gestisce queste cose:

Esiste una distinzione fondamentale tra dove si trova un oggetto nello spazio e come viene disegnato l'oggetto.

  1. Disegnare un oggetto

    Di solito hai una classe Renderer che lo fa. Prende semplicemente un oggetto (modello) e si disegna sullo schermo. Può avere metodi come drawSprite (Sprite), drawLine (..), drawModel (Model), qualunque cosa tu voglia avere bisogno. È un Renderer, quindi dovrebbe fare tutte queste cose. Utilizza inoltre qualsiasi API presente al di sotto, in modo da poter avere ad esempio un renderer che utilizza OpenGL e uno che utilizza DirectX. Se vuoi trasferire il tuo gioco su un'altra piattaforma, devi semplicemente scrivere un nuovo renderer e usarlo. È "così" facile.

  2. Spostare un oggetto

    Ogni oggetto è collegato a qualcosa che ci piace fare riferimento a SceneNode . Questo si ottiene attraverso la composizione. Un SceneNode contiene un oggetto. Questo è tutto. Cos'è un SceneNode? È una classe semplice che contiene tutte le trasformazioni (posizione, rotazione, scala) di un oggetto (di solito rispetto a un altro SceneNode) insieme all'oggetto reale.

  3. Gestire gli oggetti

    Come vengono gestiti SceneNodes? Attraverso uno SceneManager . Questa classe crea e tiene traccia di ogni SceneNode nella tua scena. Puoi richiederlo per uno SceneNode specifico (generalmente identificato da un nome stringa come "Player" o "Table") o da un elenco di tutti i nodi.

  4. Disegnare il mondo

    Questo dovrebbe essere abbastanza ovvio ormai. Cammina semplicemente attraverso ogni SceneNode nella scena e chiedi a Renderer di disegnarlo nel posto giusto. Puoi disegnarlo nel posto giusto facendo in modo che il renderer memorizzi le trasformazioni di un oggetto prima di renderlo.

  5. Rilevazione di collisioni

    Questo non è sempre banale. Di solito è possibile interrogare la scena su quale oggetto si trova in un determinato punto nello spazio o su quali oggetti intersecherà un raggio. In questo modo puoi creare un raggio dal tuo giocatore nella direzione del movimento e chiedere al direttore della scena qual è il primo oggetto che il raggio interseca. Puoi quindi scegliere di spostare il giocatore nella nuova posizione, spostarlo di una quantità inferiore (per portarlo vicino all'oggetto in collisione) o non spostarlo affatto. Assicurarsi che queste query vengano gestite da classi separate. Dovrebbero chiedere a SceneManager un elenco di SceneNodes, ma è un'altra attività per determinare se quel SceneNode copre un punto nello spazio o si interseca con un raggio. Ricorda che SceneManager crea e archivia solo nodi.

Allora, qual è il giocatore e qual è il mondo?

Il giocatore potrebbe essere una classe contenente un SceneNode, che a sua volta contiene il modello da rendere. Muovi il giocatore cambiando la posizione del nodo scena. Il mondo è semplicemente un'istanza di SceneManager. Contiene tutti gli oggetti (tramite SceneNodes). Gestisci il rilevamento delle collisioni eseguendo query sullo stato corrente della scena.

Questo è ben lungi dall'essere una descrizione completa o accurata di ciò che accade all'interno della maggior parte dei motori, ma dovrebbe aiutarti a capire i fondamenti e il motivo per cui è importante rispettare i principi OOP sottolineati da SOLID . Non rassegnarti all'idea che è troppo difficile ristrutturare il tuo codice o che non ti aiuterà davvero. In futuro vincerai molto di più progettando con cura il tuo codice.


+1 - Mi sono ritrovato a costruire qualcosa di simile nei miei sistemi di gioco e trovo che sia abbastanza flessibile.
Cypher,

+1, ottima risposta. Più concreta e precisa della mia.
jcora,

+1, ho imparato così tanto da questa risposta e ha persino avuto un finale stimolante. Grazie @rootlocus
joslinm

16

Perché dovresti evitarlo? Le dipendenze circolari dovrebbero essere evitate se si desidera creare una classe riutilizzabile. Ma il giocatore non è una classe che deve essere riutilizzabile. Ti piacerebbe mai usare il Player senza un mondo? Probabilmente no.

Ricorda che le classi non sono altro che raccolte di funzionalità. La domanda è come si divide la funzionalità. Fai tutto ciò che devi fare. Se hai bisogno di una decadenza circolare, allora così sia. (Lo stesso vale per qualsiasi funzionalità OOP a proposito. Codifica le cose in modo tale da servire a uno scopo, non seguire semplicemente i paradigmi alla cieca.)

Modifica
OK, per rispondere alla domanda: puoi evitare che il giocatore abbia bisogno di conoscere il mondo per i controlli delle collisioni usando i callback:

World::checkForCollisions()
{
  [...]
  foreach(entityA in entityList)
    foreach(entityB in entityList)
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
}

Player::onCollision(other)
{
  [... react on the collision ...]
}

Il tipo di fisica che hai descritto nella domanda può essere gestito dal mondo se esponi la velocità delle entità:

World::calculatePhysics()
{ 
  foreach(entityA in entityList)
    foreach(entityB in entityList)
    {
      [... move entityA according to its velocity as far as possible ...]
      if([... entityA has collided with the world ...])
         entityA.onWorldCollision();
      [... calculate the movement of entityB in order to know if A has collided with B ...]
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
    }
}

Tuttavia nota che probabilmente prima o poi avrai bisogno di una dipendenza dal mondo, cioè ogni volta che hai bisogno della funzionalità del mondo: vuoi sapere dov'è il nemico più vicino? Vuoi sapere quanto dista la prossima sporgenza? Dipendenza lo è.


4
+1 La dipendenza circolare non è davvero un problema qui. A questo punto non c'è motivo di preoccuparsene. Se il gioco cresce e il codice matura, probabilmente sarà una buona idea riformattare quelle classi Player e World in sottoclassi, avere un sistema basato su componenti adeguato, classi per la gestione dell'input, forse un rendering, ecc. Ma per un inizio, nessun problema.
Laurent Couvidou,

4
-1, non è sicuramente l'unica ragione per non introdurre dipendenze circolari. Non introducendoli, rendi il tuo sistema più facile da estendere e modificare.
jcora,

4
@Bane Non puoi scrivere nulla senza quella colla. La differenza è solo quanta indiretta aggiungi. Se hai le classi Gioco -> Mondo -> Entità o se hai le classi Gioco -> Mondo, SoundManager, InputManager, PhysicsEngine, ComponentManager. Rende le cose meno leggibili a causa di tutte le spese generali (sintattiche) e della complessità implicita. E a un certo punto avrai bisogno dei componenti per interagire tra loro. E questo è il punto in cui una classe di colla rende le cose più facili di tutto ciò che è diviso tra molte classi.
API-Beast,

3
No, stai spostando i pali della porta. Certo qualcosa deve chiamare render(World). Il dibattito riguarda se tutto il codice debba essere stipato all'interno di una classe o se il codice debba essere diviso in unità logiche e funzionali, che sono quindi più facili da mantenere, estendere e gestire. A proposito, buona fortuna riutilizzando quei gestori di componenti, motori di fisica e gestori di input, tutti abilmente indifferenziati e completamente accoppiati.
jcora,

1
@Bane Esistono altri modi per dividere le cose in blocchi logici oltre all'introduzione di nuove classi, tra l'altro. Puoi anche aggiungere nuove funzioni o dividere i tuoi file in più sezioni separate da blocchi di commenti. Mantenerlo semplice non significa che il codice sarà un disastro.
API-Beast,

13

Il tuo progetto attuale sembra andare contro il primo principio del design SOLID .

Questo primo principio, chiamato "principio della singola responsabilità", è generalmente una buona linea guida da seguire per non creare oggetti monolitici, fai-tutto che danneggeranno sempre il tuo design.

Per concretizzare, il tuo Worldoggetto è responsabile sia dell'aggiornamento e del mantenimento dello stato del gioco, sia del disegno di tutto.

Cosa succede se il codice di rendering cambia / deve cambiare? Perché dovresti aggiornare entrambe le classi che in realtà non hanno nulla a che fare con il rendering? Come ha già detto Liosan, dovresti avere un Renderer.


Ora, per rispondere alla tua vera domanda ...

Esistono molti modi per farlo, e questo è solo un modo di disaccoppiare:

  1. Il mondo non sa cosa sia un giocatore.
    • Ha un elenco di Objects in cui si trova il giocatore, tuttavia, ma non dipende dalla classe del giocatore (usa l'eredità per raggiungere questo obiettivo).
  2. Il giocatore viene aggiornato da alcuni InputManager.
  3. Il mondo gestisce il rilevamento dei movimenti e delle collisioni, applicando le opportune modifiche fisiche e inviando aggiornamenti agli oggetti.
    • Ad esempio, se l'oggetto A e l'oggetto B si scontrano, il mondo li informerà e quindi potrebbero gestirlo da soli.
    • Il mondo gestirà comunque la fisica (se il tuo design è così).
    • Quindi, entrambi gli oggetti possono vedere se la collisione li interessa o meno. Ad esempio, se l'oggetto A era il giocatore e l'oggetto B era un picco, allora il giocatore poteva infliggere danno a se stesso.
    • Questo può essere risolto in altri modi, però.
  4. La Rendererpareggi tutti gli oggetti.

Dici che il mondo non sa cosa sia un giocatore, ma gestisce il rilevamento delle collisioni che potrebbe aver bisogno di conoscere le proprietà del giocatore, se è uno degli oggetti in collisione.
Markus von Broady,

Eredità, il mondo deve essere consapevole di un qualche tipo di oggetto, che può essere descritto in modo generale. Il problema non è che il mondo ha solo un riferimento al giocatore, ma che potrebbe dipendere da esso come classe (cioè usare campi come quelli healthche solo questa istanza Playerha).
jcora,

Ah, vuoi dire che il mondo non ha alcun riferimento al giocatore, ha solo una serie di oggetti che implementano l'interfaccia ICollidable, insieme al giocatore, se necessario.
Markus von Broady,

2
+1 buona risposta. Ma: "per favore ignora tutte le persone che dicono che una buona progettazione del software non è importante". Comune. Nessuno l'ha detto.
Laurent Couvidou,

2
Modificato!
Sembrava

1

Il giocatore dovrebbe chiedere al mondo cose come il rilevamento delle collisioni. Il modo per evitare la dipendenza circolare non è che il mondo abbia una dipendenza dal giocatore. Il mondo ha bisogno di sapere dove si sta disegnando: probabilmente lo vorrai estratto più lontano, forse con un riferimento a un oggetto Camera che a sua volta può contenere un riferimento a qualche Entità da tracciare.

Quello che vuoi evitare in termini di riferimenti circolari non è tanto tenere i riferimenti tra loro, ma piuttosto fare riferimento l'uno all'altro in modo esplicito nel codice.


1

Ogni volta che due diversi tipi di oggetti possono chiedersi l'un l'altro. Dipenderanno l'uno dall'altro in quanto devono tenere un riferimento all'altro per chiamarne i metodi.

Puoi evitare la dipendenza circolare facendo chiedere al Mondo dal Giocatore, ma il Giocatore non può chiedere al Mondo, o viceversa. In questo modo il mondo ha riferimenti ai giocatori ma i giocatori non hanno bisogno di riferimenti al mondo. O vice versa. Ma questo non risolverà il problema, perché il mondo avrebbe bisogno di chiedere ai giocatori se hanno qualcosa da chiedere e dirglielo nella prossima chiamata ...

Quindi non puoi davvero aggirare questo "problema" e penso che non sia necessario preoccuparsene. Mantieni il design stupido e semplice il più a lungo possibile.


0

Spogliando i dettagli su player e world, hai un semplice caso di non voler introdurre una dipendenza circolare tra due oggetti (che a seconda della tua lingua potrebbe non avere importanza, vedi il link nel commento di Fuhrmanator). Esistono almeno due soluzioni strutturali molto semplici da applicare a questo e a problemi simili:

1) Introduci il modello singleton nella tua classe mondiale . Ciò consentirà al giocatore (e ogni altro oggetto) di trovare facilmente l'oggetto del mondo senza costose ricerche o collegamenti permanenti. L'essenza di questo modello è solo che la classe ha un riferimento statico all'unica istanza di quella classe, che è impostata sull'istanza dell'oggetto e cancellata sulla sua eliminazione.

A seconda del linguaggio di sviluppo e della complessità che desideri, puoi facilmente implementarlo come superclasse o interfaccia e riutilizzarlo per molte classi principali di cui non ti aspetti di avere più di uno nel tuo progetto.

2) Se il linguaggio che stai sviluppando lo supporta (molti lo fanno), usa un riferimento debole . Questo è un riferimento che non influisce su cose come la garbage collection. È utile in questi casi esattamente, assicurati di non fare ipotesi sul fatto che l'oggetto a cui fai riferimento debole esista ancora.

Nel tuo caso particolare, i tuoi giocatori potrebbero avere un debole riferimento al mondo. Il vantaggio di questo (come nel caso del singleton) è che non è necessario andare alla ricerca dell'oggetto mondiale in qualche modo per ogni frame o avere un riferimento permanente che ostacolerà i processi interessati da riferimenti circolari come la garbage collection.


0

Come hanno detto gli altri, penso che tu Worldstia facendo una cosa in più: sta cercando di contenere entrambi il gioco Map(che dovrebbe essere un'entità distinta) ed essere Rendererallo stesso tempo.

Quindi crea un nuovo oggetto (chiamato GameMap, possibilmente) e memorizza i dati a livello di mappa in esso. Scrivi funzioni che interagiscono con la mappa corrente.

Quindi hai anche bisogno di un Rendereroggetto. Si potrebbe fare questa Rendereroggetto la cosa che sia contiene GameMap e Player(così come Enemies), e li disegna anche.


-6

È possibile evitare dipendenze circolari non aggiungendo le variabili come membri. Utilizzare una funzione CurrentWorld () statica per il lettore o qualcosa del genere. Tuttavia, non inventare un'interfaccia diversa da quella implementata in World, questo è completamente inutile.

È anche possibile distruggere il riferimento prima / durante la distruzione dell'oggetto giocatore per fermare efficacemente i problemi causati dai riferimenti circolari.


1
Sono con te. OOP è troppo sopravvalutato. Tutorial e istruzione passano rapidamente a OO dopo aver appreso le cose di base del flusso di controllo. I programmi OO sono generalmente più lenti del codice procedurale, poiché esiste una burocrazia tra i tuoi oggetti, hai un sacco di accessi al puntatore, che causa shitload di cache cache. Il tuo gioco funziona ma molto lentamente. I giochi reali, molto veloci e ricchi di funzionalità che utilizzano array globali semplici e funzioni ottimizzate a mano, ottimizzate per tutto per evitare mancate cache. Ciò può comportare un aumento di dieci volte delle prestazioni.
Calmarius,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.