Plant to gra referencyjna, w której gracze sadzą i podlewają nasiona, aby później zbierać i sprzedawać powstałe rośliny.

Projekt koncentruje się na typowych przypadkach użycia, które możesz napotkać podczas tworzenia gry na Roblox. Tam, gdzie to możliwe, znajdziesz uwagi na temat kompromisów, ustępstw i uzasadnienia różnych wyborów implementacyjnych, abyś mógł podjąć najlepszą decyzję dla swoich gier.
Pobierz plik
- Przejdź do strony gry Plant.
- Kliknij przycisk ⋯ i Edytuj w Studio.
Przypadki użycia
Plant obejmuje następujące przypadki użycia:
- Utrzymywanie danych sesji i danych gracza
- Zarządzanie widokiem UI
- Sieciowanie klient-serwer
- Doświadczenie pierwszego użytkownika (FTUE)
- Zakupy za pomocą waluty twardej i miękkiej
Dodatkowo, ten projekt rozwiązuje węższe zestawy problemów, które są stosowane w wielu grach, w tym:
- Personalizacja obszaru w miejscu związanym z graczem
- Zarządzanie prędkością ruchu postaci gracza
- Tworzenie obiektu, który podąża za postaciami
- Wykrywanie, w której części świata znajduje się postać
Zauważ, że w tej grze istnieje kilka przypadków użycia, które są zbyt małe, zbyt niszowe lub nie demonstrują rozwiązania interesującego wyzwania projektowego; te nie są objęte.
Struktura projektu
Pierwszą decyzją przy tworzeniu gry jest ustalenie, jak zorganizować projekt, co głównie obejmuje, gdzie umieścić konkretne instancje w modelu danych oraz jak zorganizować i strukturyzować punkty wejścia dla kodu klienta i serwera.
Model danych
Poniższa tabela opisuje, w których usługach kontenerowych w modelu danych umieszczane są instancje.
| Usługa | Typy instancji |
|---|---|
| Workspace | Zawiera statyczne modele reprezentujące świat 3D, a konkretnie części świata, które nie należą do żadnego gracza. Nie musisz dynamicznie tworzyć, modyfikować ani niszczyć tych instancji w czasie wykonywania, więc akceptowalne jest pozostawienie ich tutaj. Istnieje również pusta Folder, do której modele farm graczy będą dodawane w czasie wykonywania. |
| Lighting | Efekty atmosferyczne i oświetleniowe. |
| ReplicatedFirst | Zawiera najmniejszy możliwy podzbiór instancji potrzebnych do wyświetlenia ekranu ładowania i zainicjowania gry. Im więcej instancji umieszczonych w ReplicatedFirst, tym dłużej trwa ich replikacja przed uruchomieniem kodu w ReplicatedFirst.
|
| ReplicatedStorage | Pełni rolę kontenera do przechowywania wszystkich instancji, do których dostęp jest wymagany zarówno po stronie klienta, jak i serwera.
|
| ServerScriptService | Zawiera Script, który pełni rolę punktu wejścia dla całego kodu po stronie serwera w projekcie. |
| ServerStorage | Pełni rolę kontenera do przechowywania wszystkich instancji, które nie muszą być replikowane do klienta.
|
| SoundService | Zawiera obiekty Sound używane do efektów dźwiękowych w grze. Pod SoundService te obiekty Sound nie mają pozycji i nie są symulowane w przestrzeni 3D. |
Punkty wejścia
Większość projektów organizuje kod w wewnętrznych ModuleScripts, które mogą być importowane w całej bazie kodu. ModuleScripts są wielokrotnego użytku, ale nie wykonują się same; muszą być importowane przez Script lub LocalScript. Wiele projektów Roblox ma dużą liczbę obiektów Script i LocalScript, z których każdy dotyczy zachowania lub konkretnego systemu w grze, tworząc wiele punktów wejścia.
Dla mikrogry Plant zastosowano inne podejście poprzez pojedynczy LocalScript, który jest punktem wejścia dla całego kodu klienta, oraz pojedynczy Script, który jest punktem wejścia dla całego kodu serwera. Odpowiednie podejście dla twojego projektu zależy od twoich wymagań, ale pojedynczy punkt wejścia zapewnia większą kontrolę nad kolejnością, w jakiej systemy są uruchamiane.
Poniższe listy opisują kompromisy obu podejść:
- Pojedynczy Script i pojedynczy LocalScript pokrywają odpowiednio kod serwera i klienta.
- Większa kontrola nad kolejnością, w jakiej różne systemy są uruchamiane, ponieważ cały kod jest inicjowany z jednego skryptu.
- Możliwość przekazywania obiektów przez referencję między systemami.
Architektura systemów na wysokim poziomie
Najwyższe systemy w projekcie są szczegółowo opisane poniżej. Niektóre z tych systemów są znacznie bardziej złożone niż inne, a w wielu przypadkach ich funkcjonalność jest abstrakcyjna w hierarchii innych klas.

Każdy z tych systemów jest "singletonem", co oznacza, że jest to klasa, która nie może być instancjonowana, a zamiast tego jest inicjowana przez odpowiedni skrypt start klienta lub serwera. Możesz przeczytać więcej o wzorcu singletona później w tym przewodniku.
Serwer
Poniższe systemy są związane z serwerem.
| System | Opis |
|---|---|
| Sieć |
|
| PlayerDataServer |
|
| Rynek |
|
| CollisionGroupManager |
|
| FarmManagerServer |
|
| PlayerObjectsContainer |
|
| TagPlayers |
|
| FtueManagerServer |
|
| CharacterSpawner |
|
Klient
Poniższe systemy są związane z klientem.
| System | Opis |
|---|---|
| Sieć |
|
| PlayerDataClient |
|
| MarketClient |
|
| LocalWalkJumpManager |
|
| FarmManagerClient |
|
| UISetup |
|
| FtueManagerClient |
|
| CharacterSprint |
|
Komunikacja klient-serwer
Większość gier Roblox obejmuje jakiś element komunikacji między klientem a serwerem. Może to obejmować żądanie klienta, aby serwer wykonał określoną akcję oraz replikację aktualizacji do klienta.
W tym projekcie komunikacja klient-serwer jest utrzymywana tak ogólnie, jak to możliwe, poprzez ograniczenie użycia obiektów RemoteEvent i RemoteFunction, aby zmniejszyć liczbę specjalnych reguł do śledzenia. Ten projekt wykorzystuje następujące metody, w kolejności preferencji:
- Replikacja za pomocą systemu danych gracza.
- Replikacja za pomocą atrybutów.
- Replikacja za pomocą tagów.
- Wysyłanie wiadomości bezpośrednio za pomocą modułu Sieć.
Replikacja za pomocą systemu danych gracza
System danych gracza pozwala na powiązanie danych z graczem, które utrzymują się między sesjami zapisu. Ten system zapewnia replikację z klienta do serwera oraz zestaw interfejsów API, które można wykorzystać do zapytywania o dane i subskrybowania zmian, co czyni go idealnym do replikacji zmian stanu gracza z serwera do klienta.
Na przykład, zamiast uruchamiać specjalny UpdateCoins RemoteEvent, aby powiedzieć klientowi, ile ma monet, możesz wywołać następujące i pozwolić klientowi subskrybować to za pomocą zdarzenia PlayerDataClient.updated.
PlayerDataServer.setValue(player, "coins", 5)Oczywiście, to jest użyteczne tylko dla replikacji z serwera do klienta i dla wartości, które chcesz, aby utrzymywały się między sesjami, ale dotyczy to zaskakującej liczby przypadków w projekcie, w tym:
- Bieżący etap FTUE
- Inwentarz gracza
- Ilość monet, które ma gracz
- Stan farmy gracza
Replikacja za pomocą atrybutów
W sytuacjach, gdy serwer musi zreplikować niestandardową wartość do klienta, która jest specyficzna dla danej Instance, możesz użyć atrybutów. Roblox automatycznie replikuje wartości atrybutów, więc nie musisz utrzymywać żadnych ścieżek kodu do replikacji stanu powiązanego z obiektem. Inną zaletą jest to, że ta replikacja odbywa się równolegle z instancją.
Jest to szczególnie przydatne dla instancji tworzonych w czasie wykonywania, ponieważ atrybuty ustawione na nowej instancji przed jej umiejscowieniem w modelu danych będą replikowane atomowo z samą instancją. To omija potrzebę pisania kodu, aby "czekać" na dodatkowe dane do zreplikowania za pomocą RemoteEvent lub StringValue.
Możesz również bezpośrednio odczytać atrybuty z modelu danych, zarówno z klienta, jak i serwera, za pomocą metody GetAttribute(), oraz subskrybować zmiany za pomocą metody GetAttributeChangedSignal(). W projekcie Plant to podejście jest używane między innymi do replikacji bieżącego statusu roślin do klientów.
Replikacja za pomocą tagów
CollectionService pozwala na zastosowanie tagu tekstowego do Instance. Jest to przydatne do kategoryzowania instancji i replikacji tej kategoryzacji do klienta.
Na przykład, tag CanPlant jest stosowany na serwerze, aby oznaczyć klientowi, że dany doniczkowy jest w stanie przyjąć roślinę.
Wysyłanie wiadomości bezpośrednio za pomocą modułu sieciowego
W sytuacjach, gdy żadne z poprzednich opcji nie mają zastosowania, możesz użyć niestandardowych wywołań sieciowych za pośrednictwem modułu Sieć. To jest jedyna opcja w projekcie, która pozwala na komunikację z klienta do serwera i jest zatem najbardziej przydatna do przesyłania żądań klienta i otrzymywania odpowiedzi od serwera.
Plant wykorzystuje bezpośrednie wywołania sieciowe do różnych żądań klienta, w tym:
- Podlewanie rośliny
- Sadzenie nasiona
- Zakup przedmiotu
Wadą tego podejścia jest to, że każda pojedyncza wiadomość wymaga pewnej specjalnej konfiguracji, co może zwiększyć złożoność projektu, chociaż tego unikano, gdzie to możliwe, szczególnie w przypadku komunikacji z serwera do klienta.
Klasy i singletony
Klasy w projekcie Plant, podobnie jak instancje w Roblox, mogą być tworzone i niszczone. Ich składnia klas jest inspirowana idiomatycznym podejściem Lua do programowania obiektowego z wieloma zmianami, aby umożliwić wsparcie dla ścisłej kontroli typów.
Instancjonowanie
Wiele klas w projekcie jest związanych z jedną lub więcej Instances. Obiekty danej klasy są tworzone za pomocą metody new(), zgodnie z tym, jak instancje są tworzone w Roblox za pomocą Instance.new().
Ten wzorzec jest zazwyczaj używany dla obiektów, gdzie klasa ma fizyczną reprezentację w modelu danych, a klasa rozszerza swoją funkcjonalność. Dobrym przykładem jest BeamBetween, który tworzy obiekt Beam między dwoma danymi obiektami Attachment i utrzymuje te załączniki w orientacji, aby wiązka zawsze była skierowana w górę. Te instancje mogą być klonowane z prefabrykowanej wersji w ReplicatedStorage lub przekazywane do new() jako argument i przechowywane wewnątrz obiektu pod self.
Odpowiednie instancje
Jak wspomniano powyżej, wiele klas w tym projekcie ma reprezentację w modelu danych, instancję, która odpowiada klasie i jest przez nią manipulowana.
Zamiast tworzyć te instancje, gdy obiekt klasy jest instancjonowany, kod zazwyczaj decyduje się na Clone() prefabrykowanej wersji Instance przechowywanej w ReplicatedStorage lub ServerStorage. Chociaż możliwe byłoby serializowanie właściwości tych instancji i tworzenie ich od podstaw w funkcjach new() klasy, uczyniłoby to edytowanie obiektów bardzo uciążliwym i trudnym do zrozumienia dla czytelnika. Dodatkowo, klonowanie instancji jest zazwyczaj szybszą operacją niż tworzenie nowej instancji i dostosowywanie jej właściwości w czasie wykonywania.
Kompozycja
Chociaż dziedziczenie jest możliwe w Luau za pomocą metatables, projekt decyduje się na umożliwienie klasom rozszerzania się nawzajem poprzez kompozycję. Podczas łączenia klas poprzez kompozycję, obiekt "dziecka" jest instancjonowany w metodzie new() klasy i jest uwzględniany jako członek pod self.
Aby zobaczyć przykład tego w działaniu, zobacz klasę CloseButton, która opakowuje klasę Button.
Czyszczenie
Podobnie jak instancja Instance może być zniszczona za pomocą metody Destroy(), klasy, które mogą być instancjonowane, mogą również być niszczone. Metoda destruktora dla klas projektu to destroy() z małą literą d dla spójności camelCase w metodach bazy kodu, a także aby odróżnić klasy projektu od instancji Roblox.
Rola metody destroy() polega na zniszczeniu wszelkich instancji utworzonych przez obiekt, odłączeniu wszelkich połączeń i wywołaniu destroy() na wszelkich obiektach podrzędnych. Jest to szczególnie ważne dla połączeń, ponieważ instancje z aktywnymi połączeniami nie są czyszczone przez zbieracz śmieci Luau, nawet jeśli nie pozostają żadne odniesienia do instancji ani połączenia z instancją.
Singletony
Singletony, jak sama nazwa wskazuje, to klasy, dla których może istnieć tylko jeden obiekt. Są one odpowiednikiem usług Services w Roblox. Zamiast przechowywać odniesienie do obiektu singletona i przekazywać je w kodzie Luau, Plant korzysta z faktu, że wymaganie ModuleScript buforuje jego zwróconą wartość. Oznacza to, że wymaganie tego samego singletona ModuleScript z różnych miejsc konsekwentnie zapewnia ten sam zwrócony obiekt. Jedynym wyjątkiem od tej zasady byłoby, gdyby różne środowiska (klient lub serwer) uzyskiwały dostęp do ModuleScript.
Singletony różnią się od klas instancjonowanych tym, że nie mają metody new(). Zamiast tego obiekt wraz z jego metodami i stanem jest zwracany bezpośrednio przez ModuleScript. Ponieważ singletony nie są instancjonowane, składnia self nie jest używana, a metody są wywoływane za pomocą kropki (.) zamiast dwukropka (:).
Ścisła inferencja typów
Luau wspiera stopniowe typowanie, co oznacza, że możesz dodać opcjonalne definicje typów do niektórych lub wszystkich swoich kodów. W tym projekcie używana jest ścisła kontrola typów dla każdego skryptu. Jest to najmniej pobłażliwa opcja dla narzędzia Analiza skryptów w Roblox i zatem najbardziej prawdopodobna do wychwycenia błędów typów przed czasem wykonywania.
Składnia klas typowanych
Ustanowione podejście do tworzenia klas w Lua jest dobrze udokumentowane, jednak nie jest dobrze dostosowane do silnego typowania Luau. W Luau najprostszym podejściem do uzyskania typu klasy jest metoda typeof():
type ClassType = typeof(Class.new())To działa, ale nie jest zbyt użyteczne, gdy twoja klasa jest inicjowana wartościami, które istnieją tylko w czasie wykonywania, na przykład obiektami Player. Dodatkowo, założenie przyjęte w idiomatycznej składni klas Lua jest takie, że deklarowanie metody na klasie self zawsze będzie instancją tej klasy; to nie jest założenie, które silnik inferencji typów może przyjąć.
Aby wspierać ścisłą inferencję typów, projekt Plant używa rozwiązania, które różni się od idiomatycznej składni klas Lua na wiele sposobów, z których niektóre mogą wydawać się nieintuicyjne:
- Definicja self jest powielana, zarówno w deklaracji typu, jak i w konstruktorze. To wprowadza obciążenie związane z utrzymywaniem, ale ostrzeżenia będą zgłaszane, jeśli dwie definicje przestaną być zgodne.
- Metody klas są deklarowane za pomocą kropki, więc self może być jawnie zadeklarowane jako typu ClassType. Metody nadal mogą być wywoływane za pomocą dwukropka, jak oczekiwano.
--!strict
local MyClass = {}
MyClass.__index = MyClass
export type ClassType = typeof(setmetatable(
{} :: {
property: number,
},
MyClass
))
function MyClass.new(property: number): ClassType
local self = {
property = property,
}
setmetatable(self, MyClass)
return self
end
function MyClass.addOne(self: ClassType)
self.property += 1
end
return MyClassRzutowanie typów po warunkach logicznych
W momencie pisania, typ wartości nie jest zawężany po instrukcji warunkowej. Na przykład, po warunku poniżej, typ optionalParameter nie jest zawężany do number.
--!strict
local function foo(optionalParameter: number?)
if not optionalParameter then
return
end
print(optionalParameter + 1)
endAby to złagodzić, nowe zmienne są tworzone po tych warunkach z ich typem jawnie rzuconym.
--!strict
local function foo(optionalParameter: number?)
if not optionalParameter then
return
end
local parameter = optionalParameter :: number
print(parameter + 1)
endPrzechodzenie przez hierarchie DataModel
W niektórych przypadkach, baza kodu musi przechodzić przez hierarchię modelu danych drzewa obiektów, które są tworzone w czasie wykonywania. To stawia interesujące wyzwanie dla kontroli typów. W momencie pisania, nie jest możliwe zdefiniowanie ogólnej hierarchii modelu danych jako typu. W rezultacie, istnieją przypadki, w których jedyną informacją o typie dostępną dla struktury modelu danych jest typ instancji najwyższego poziomu.
Jednym z podejść do tego wyzwania jest rzutowanie na any, a następnie udoskonalanie. Na przykład:
local function enableVendor(vendor: Model)
local zonePart: BasePart = (vendor :: any).ZonePart
endProblem z tym podejściem polega na tym, że wpływa na czytelność. Zamiast tego, projekt używa ogólnego modułu o nazwie getInstance do przechodzenia przez hierarchie modelu danych, który wewnętrznie rzutuje na any.
local function enableVendor(vendor: Model)
local zonePart: BasePart = getInstance(vendor, "ZonePart")
endW miarę jak zrozumienie silnika typów modelu danych ewoluuje, możliwe, że wzorce takie jak ten nie będą już konieczne.
Interfejs użytkownika
Plant zawiera różnorodne złożone i proste interfejsy użytkownika 2D. Obejmują one nieinteraktywne elementy wyświetlacza (HUD), takie jak licznik monet oraz złożone interaktywne menu, takie jak sklep.
Podejście do UI
Możesz luźno porównać interfejs UI Roblox do HTML DOM, ponieważ jest to hierarchia obiektów, które opisują, co użytkownik powinien widzieć. Podejścia do tworzenia i aktualizowania interfejsu Roblox są szeroko podzielone na praktyki imperatywne i deklaratywne.
| Podejście | Zalety i wady |
|---|---|
| Imperatywne | W podejściu imperatywnym, UI traktowane jest jak każda inna hierarchia instancji w Roblox. Struktura UI jest tworzona przed czasem wykonywania w Studio i dodawana do modelu danych, zazwyczaj bezpośrednio w StarterGui. Następnie, w czasie wykonywania, kod manipuluje konkretnymi elementami UI, aby odzwierciedlić stan, który wymaga twórca. To podejście ma pewne zalety. Możesz stworzyć UI od podstaw w Studio i przechować je w modelu danych. To jest proste i wizualne doświadczenie edycyjne, które może przyspieszyć tworzenie UI. Ponieważ kod UI imperatywnego dotyczy tylko tego, co wymaga zmiany, również ułatwia to wprowadzanie prostych zmian w UI. Znaczną wadą jest to, że ponieważ podejścia imperatywne wymagają, aby stan był ręcznie wdrażany w formie transformacji, złożone reprezentacje stanu mogą stać się bardzo trudne do znalezienia i debugowania. Często pojawiają się błędy podczas rozwijania kodu UI imperatywnego, zwłaszcza gdy stan i UI stają się niesynchronizowane z powodu interakcji wielu aktualizacji w nieoczekiwanej kolejności. Kolejnym wyzwaniem w podejściu imperatywnym jest to, że trudniej jest podzielić UI na znaczące komponenty, które można zadeklarować raz i ponownie wykorzystać. Ponieważ całe drzewo UI jest deklarowane w czasie edycji, wspólne wzorce mogą być powtarzane w wielu częściach modelu danych. |
| Deklaratywne | W podejściu deklaratywnym, pożądany stan instancji UI jest jawnie deklarowany, a efektywna implementacja tego stanu jest abstrahowana przez biblioteki takie jak Roact lub Fusion. Zaletą tego podejścia jest to, że implementacja stanu staje się trywialna i musisz tylko opisać, jak chcesz, aby twój interfejs wyglądał. To znacznie ułatwia identyfikację i rozwiązywanie błędów. Główną wadą jest konieczność zadeklarowania całego drzewa UI w kodzie. Biblioteki takie jak Roact i Fusion mają składnię, aby to ułatwić, ale wciąż jest to czasochłonny proces i mniej intuicyjne doświadczenie edycyjne podczas komponowania UI. |
Plant używa podejścia imperatywnego w przekonaniu, że bezpośrednie pokazywanie transformacji daje bardziej efektywny przegląd tego, jak UI jest tworzone i manipulowane w Roblox. To nie byłoby możliwe w podejściu deklaratywnym. Niektóre powtarzające się struktury UI i logika są również abstrahowane w wielokrotnego użytku komponenty, aby uniknąć powszechnej pułapki w projektowaniu UI imperatywnym.
Architektura na wysokim poziomie

Warstwa i komponenty
W Plant wszystkie struktury UI są albo Layer, albo Component.
- Layer jest definiowany jako singleton grupujący na najwyższym poziomie, który opakowuje prefabrykowane struktury UI w ReplicatedStorage. Warstwa może zawierać wiele komponentów lub może całkowicie encapsulować swoją logikę. Przykłady warstw to menu inwentarza lub wskaźnik liczby monet w wyświetlaczu.
- Component to wielokrotnego użytku element UI. Gdy nowy obiekt komponentu jest instancjonowany, klonuje prefabrykowany szablon z ReplicatedStorage. Komponenty mogą same w sobie zawierać inne komponenty. Przykłady komponentów to ogólna klasa przycisku lub koncepcja listy przedmiotów.
Zarządzanie widokiem
Typowym problemem zarządzania UI jest zarządzanie widokiem. Ten projekt ma szereg menu i elementów HUD, z których niektóre nasłuchują na wejście użytkownika, a staranne zarządzanie tym, kiedy są widoczne lub włączone, jest wymagane.
Plant podchodzi do tego problemu za pomocą systemu UIHandler, który zarządza tym, kiedy warstwa UI powinna być widoczna lub nie. Wszystkie warstwy UI w grze są klasyfikowane jako HUD lub Menu, a ich widoczność jest zarządzana przez następujące zasady:
- Stan włączony warstw Menu i HUD może być przełączany.
- Włączone warstwy HUD są pokazywane tylko wtedy, gdy żadne warstwy Menu nie są włączone.
- Włączone warstwy Menu są przechowywane w stosie, a tylko jedna warstwa Menu jest widoczna w danym czasie. Gdy warstwa Menu jest włączona, jest wstawiana na przód stosu i pokazywana. Gdy warstwa Menu jest wyłączona, jest usuwana ze stosu, a następna włączona warstwa Menu w kolejce jest pokazywana.
To podejście jest intuicyjne, ponieważ pozwala na nawigację po menu z historią. Jeśli jedno menu jest otwierane z innego menu, zamknięcie nowego menu ponownie pokaże stare menu.
Dalsze czytanie
Z tego szczegółowego przeglądu projektu Plant możesz chcieć zbadać następujące przewodniki, które idą dalej w głąb pokrewnych koncepcji i tematów.
- Model klient-serwer — Przegląd modelu klient-serwer w Roblox.
- Zdarzenia zdalne i wywołania zwrotne — Wszystko o zdalnych zdarzeniach sieciowych i wywołaniach zwrotnych do komunikacji przez granicę klient-serwer.
- UI — Szczegóły dotyczące obiektów interfejsu użytkownika i projektowania w Roblox.