Development Guide
Project
Directory structure
Overview
- Assets
- AddressableAssetsData
- Settings for using Addressables
- Analyzer
- Settings for using Code Cracker
- Google Fonts
- Unity font assets created from Noto Sans Japanese
- Holiday
- Assets created in the Holiday application
- Holiday.PerformanceTest
- Materials for performance testing in Holiday
- Mixamo
- Unity character models created from Mixamo
- StarterAssets
- TextMesh Pro
- Settings for using TextMesh Pro
- URP
- Configuration for using Universal Render Pipeline
- WebGLScripts
- Assets for WebGL to be created in Holiday application
- WebGLTemplates
- Template for WebGL for use in Holiday application
- AddressableAssetsData
- Packages
- Unity packages for application development
- Servers
- Server scripts for use in Holiday application
- WebGLBuild
- Script to deliver Holiday application for WebGL at development
We have a directory named starting with Holiday where we store our own assets. This is to make it easier to distinguish between assets created by third parties and ourselves. Directories other than Holiday are settings created for third parties or when using Unity features. We have set up directories for applications, so all other directories have been created or named with names that identify them as third-party.
Holiday
- App
- Application entry point
- Application-wide settings/status
- AppUsage
- Processing for application usage visualization
- AssetWorkflow
- Common processing for asset downloads
- Avatars
- Avatar prefabs
- Config
- Application settings
- Group
- Processing common to group chat
- Stages
- Processing common to the stages
- Controls
- XxxControl
- Control scene. Assets for each scene are placed together.
- Common
- Processing common to all Control scenes.
- XxxControl
- Screens
- XxxScreen
- Screen scene. Assets for each scene are placed together.
- Common
- UI and processing common to all Screen scenes.
- XxxScreen
- Spaces
- XxxSpace
- Space scene. Assets for each scene are placed together.
- Common
- UI and processing common to all Space scenes.
- XxxSpace
In many Unity applications, directories are divided by asset type, but since the assets that make up a single feature are scattered and difficult to find, Holiday will divide the directories by scene and place the assets for each scene together.
The application structure is shown below so that the correspondence between directories and scenes can be understood.
Static analysis
Analyzer is configured in .editorconfig
directly under root.
Edit the script using an editor that supports EditorConfig and make sure there are no problems with Analyzer before pushing.
Assembly Definition(AD)
Create one AD per application unit.
- To limit third-party packages used in the application
- To facilitate the introduction of automated testing
- To apply Code Cracker
- To apply Code Cracker, the AD to be checked must include Code Cracker.
File settings
Scripts and configuration files should have the following settings to avoid unintended change differences.
- Encoding:
UTF-8 with BOM
- Newline code:
LF
It is set as UTF-8 with BOM
because UTF-8 without BOM
cannot be set in Visual Studio.
Application
Entry point
The entry point for the application is the following scene.
Assets/Holiday/App/App
Initialization
The initialization processing of the application is performed by InitializeApp in the following script. InitializeApp is called at the beginning of Awake.
Assets/Holiday/App/AppScope
It sets the frame rate and Logging settings.
Stage configuration
Three types of stage configurations are created as shown in Stage Navigation Settings. Change them when you create a stage or scene.
Assets/Holiday/App/Config/StageName
Assets/Holiday/App/Config/SceneName
Assets/Holiday/App/Config/StageConfig
Application state
The class to hold the application state is provided. It is used to hold the player's name, selected avatar, and information across scenes.
Assets/Holiday/App/AppState
Utility class
Processing common to the entire application is provided as utility classes.
Assets/Holiday/App/AppUtils
Objects
Use VContainer to manage the objects used in each scene.
DI
For more information on DI, see What is DI ?.
There are several ways to do DI. VContainer recommends the following methods.
- C# Type
- MonoBehaviour
See RECOMMENDATION in Constructor Injection for VContainer's recommended reasons.
The following Register is used to register to VContainer.
builder.Register<AppState>(Lifetime.Singleton);
builder.RegisterComponent(networkManager);
RegisterInstance and RegisterComponent can be used to register instances such as MonoBehaviour, but RegisterComponent should be used. RegisterInstance does not perform DI on the argument object, so if you mistakenly specify an object for which you want to use DI, it may take some time to resolve the problem.
builder.RegisterEntryPoint<AppPresenter>();
Scope
In VContainer, an object attached to a class that extends LifetimeScope is placed in a scene, which represents one scope (one container). By specifying the scope of the parent in the scope, the object search can be extended to the parent. Holiday uses the parent scope specification to make objects in the common scene available to each scene.
Common scenes for Holiday are as follows.
- App scene
- ClientControl scene
- Vivox client and NGO client are provided.
Holiday's scope hierarchy is as follows.
App scene
↑
Each scene (Control scene, Screen scene, Space scene)
or
App scene
↑
ClientControl scene
↑
Each scene (Control scene, Screen scene, Space scene)
Specify the scope of the common scene as the parent of each scene scope.
Dispose pattern
It is recommended to implement the Dispose Pattern in the class that disposes. To standardize the implementation of the Dispose Pattern, use the classes provided by Common instead of implementing the Dispose Pattern individually.
Assets
Holiday externalizes content so that necessary content can be downloaded and used in applications. This is to allow only the content to be changed, to reduce the size of the application and to shorten the download time.
Holiday uses AssetWorkflow.addressables for asset management.
The asset groups are as follows. The asset is downloaded only the first time it is used, and the cache is used thereafter.
- AppCommon
- Assets common to the entire application
- Message/chat/multiplayer settings, 3D models of avatars, etc
- Download timing
- Before transitioning to the avatar selection stage
- VirtualSpace
- Assets for VirtualSpace stage
- 3D model of virtual space, etc.
- Download timing
- Before transitioning to VirtualSpace stage
- Duplicate Asset Isolation
- Duplicate assets in multiple asset groups
- Download timing
- Same as AppCommon
The following rules apply to remote base URLs, and assets are placed for each version of the application.
Remote base URL:
https://<host>/<version>/<target>
Example:
https://<host>/1.0.0/Android
https://<host>/1.0.0/iOS
https://<host>/1.0.0/StandaloneWindows64
https://<host>/1.1.0/Android
https://<host>/1.1.0/iOS
https://<host>/1.1.0/StandaloneWindows64
When developing, you can play without downloading by specifying Use Asset Database
for Play Mode Script
in the settings of Addressables Groups
.
Scenes
Design policy
The following is a design policy for scenes. Each scene should be created according to this design policy. By unifying the way each scene is created, we aim to create an application that is easy to maintain.
- Scenes are created using the MV(R)P pattern
- Models used throughout the application will be managed in the scope of App scenes
- StageNavigator, AppState, repositories for data access, NetworkManager, etc.
- Use AppState to exchange information across scenes
- Models used only in each scene are managed in the scope of each scene
- MultiplayRoom, TextChatChannel, etc.
- The scene loading/unloading process is handled by IInitializable/IDisposable
- This is done by implementing IInitializable/IDisposable in Presenter
- Note that the timing when IInitializable/IDisposable is called is not the timing of stage transitions. If the same scene continues at the stage transition, the scene is reused and IIInitializable/IDisposable is not called
- StageNavigator's event notification is used for processing at stage transitions
- Implement IInitializable in Presenter to register StageNavigator's event subscription
- If you want to perform processing for each space, such as connecting/disconnecting to a multiplayer room or text chat channel, implement it as a processing at the time of stage transition
Base class for Presenter
A base class is provided for the processing calls during stage transitions, as this processing is common to all presenter scripts in each scene.
Assets/Holiday/App/Common/StagePresenterBase
A template is provided, so please create a presenter script from the Presenter Template
.
The Base class provides the following common processing.
- Calls for initialization when the scene is loaded
- Calls for processing when entering and exiting the stage
- Call to Dispose when exiting the stage
- Call to Dispose when the scene is unloaded
The following is a template for a Presenter script that inherits from the Base class.
public class XxxxxPresenter : StagePresenterBase
{
public XxxxxPresenter(StageNavigator<StageName, SceneName> stageNavigator) : base(stageNavigator)
{
// Constructor
// Pass StageNavigator to the Base class.
// Receive the objects needed for the scene via constructor injection.
}
protected override void Initialize(StageNavigator<StageName, SceneName> stageNavigator, CompositeDisposable sceneDisposables)
{
// Implement the initialization processing here when the scene is loaded.
// Add objects to sceneDisposables that you want to dispose of when unloading the scene.
}
protected override void OnStageEntered(StageName stageName, CompositeDisposable stageDisposables)
{
// Implement the processing when entering the stage here.
// Add to stageDisposables the objects you want to dispose of when exiting the stage.
}
protected override void OnStageExiting(StageName stageName)
{
// Implement the process for exiting the stage here.
}
}
Basic structure
For each scene, create a GameObject with the following asset name.
- Scope
- An empty GameObject to attach the LifetimeScope of the VContainer
- View
- An empty GameObject to attach a View
- (arbitrary name)
- Screen/Space scene
- GameObjects such as UI of the screen or 3D objects in space
- Control scene
- GameObjects such as cameras, etc.
- Screen/Space scene
There is no need to create objects that are not necessary to realize the feature.
The objects and scripts of the avatar selection screen scene are shown below for reference.
The avatar selection screen scene allows players to enter their name, select an avatar and transition to the next screen.
public class AvatarSelectionScreenScope : LifetimeScope
{
[SerializeField] private AvatarSelectionScreenView avatarSelectionScreenView;
protected override void Configure(IContainerBuilder builder)
{
builder.RegisterComponent(avatarSelectionScreenView);
builder.RegisterEntryPoint<AvatarSelectionScreenPresenter>();
}
}
AvatarSelectionScreenScope registers a View and a Presenter in a scope. The Presenter is registered as an entry point and serves as the starting point for scene processing. Refer to Plain C# Entry point for the timing of processing that can be used at the entry point.
public class AvatarSelectionScreenPresenter : StagePresenterBase
{
private static readonly ELogger Logger = LoggingManager.GetLogger(nameof(AvatarSelectionScreenPresenter));
private readonly AvatarService avatarService;
private readonly AvatarSelectionScreenView avatarSelectionScreenView;
private readonly AppState appState;
public AvatarSelectionScreenPresenter
(
StageNavigator<StageName, SceneName> stageNavigator,
AvatarService avatarService,
AvatarSelectionScreenView avatarSelectionScreenView,
AppState appState
) : base(stageNavigator)
{
this.avatarSelectionScreenView = avatarSelectionScreenView;
this.avatarService = avatarService;
this.appState = appState;
}
protected override void Initialize(
StageNavigator<StageName, SceneName> stageNavigator, CompositeDisposable sceneDisposables)
{
avatarSelectionScreenView.OnNameChanged
.Subscribe(appState.SetPlayerName)
.AddTo(sceneDisposables);
avatarSelectionScreenView.OnAvatarChanged
.Subscribe(avatarName =>
{
var avatar = avatarService.FindAvatarByName(avatarName);
appState.SetAvatar(avatar);
})
.AddTo(sceneDisposables);
avatarSelectionScreenView.OnGoButtonClicked
.Subscribe(_ => stageNavigator.ReplaceAsync(StageName.VirtualStage).Forget())
.AddTo(sceneDisposables);
}
protected override void OnStageEntered(StageName stageName, CompositeDisposable stageDisposables)
{
var avatars = avatarService.Avatars;
if (appState.Avatar.Value == null)
{
appState.SetAvatar(avatars.First());
}
var avatarNames = avatars.Select(avatar => avatar.Name).ToList();
avatarSelectionScreenView.Initialize(avatarNames);
avatarSelectionScreenView.SetInitialValues(appState.PlayerName.Value, appState.Avatar.Value.Name);
if (Logger.IsDebug())
{
Logger.LogDebug($"player: name: {appState.PlayerName.Value} avatar: {appState.Avatar.Value.Name}");
}
}
protected override void OnStageExiting(StageName stageName)
{
}
}
Presenter injects Views and Models and performs View and Model processing mapping and stage transitions. Since the App scene scope is specified as the parent of the scope, StageNavigator and AppState managed by the App scene can be set and used.
public class AvatarSelectionScreenView : MonoBehaviour
{
[SerializeField] private TMP_InputField nameInputField;
[SerializeField] private TMP_Dropdown avatarDropdown;
[SerializeField] private Button goButton;
private readonly List<string> avatarNames = new List<string>();
public void Initialize(List<string> avatarNames)
{
this.avatarNames.Clear();
this.avatarNames.AddRange(avatarNames);
avatarDropdown.options =
this.avatarNames.Select(avatarName => new TMP_Dropdown.OptionData(avatarName)).ToList();
}
public void SetInitialValues(string name, string avatarName)
{
nameInputField.text = name;
avatarDropdown.value = avatarNames.IndexOf(avatarName);
}
public IObservable<string> OnNameChanged =>
nameInputField.onEndEdit.AsObservable().TakeUntilDestroy(this);
public IObservable<string> OnAvatarChanged =>
avatarDropdown.onValueChanged.AsObservable()
.TakeUntilDestroy(this).Select(index => avatarNames[index]);
public IObservable<Unit> OnGoButtonClicked => goButton.OnClickAsObservable().TakeUntilDestroy(this);
}
View initializes the avatar pull-down, sets initial values for input items and notifies input item events.
UI
Font
Noto Sans Japanese has already been imported and set as the default for TextMesh Pro. Please use Noto Sans Japanese font.
Resolution
The resolution will be based on the generic size of 1920x1080. PC is assumed to be used at 1920x1080 and mobile at 1080x1920 in portrait mode. Add 1080x1920 to the GameView resolution and check the two sizes on the Unity editor.
Modularization
UIs that share a common design, such as screens and buttons, should be standardized for ease of maintenance. Use Unity's Prefab for UI commonization. If you have added a Prefab, please add it below.
Assets/Holiday/Screens/Common
- ScreenCanvas
- Canvas for screen
- Includes background color specification and SafeArea support
- Place the screen UI under the SafeArea
- ScreenTitle
- Title for the screen
- Contains font and character settings
- ScreenButton
- Button for the screen
- Contains font and character settings
Assets/Holiday/Controls/Common
- SpaceCanvas
- Canvas for space
- Contains SafeArea support
- Place the screen UI under the SafeArea
- SpaceButton
- Button for space
- Contains font and character settings
Canvas
Specify SortOrder for the Canvas of common screens that are superimposed on a screen or space, such as background screens and loading screens, so that they are displayed in the front.
Communication
Per Space
Multiplayer and text chat use messaging. Multiplayer/text chat per space is realized by joining with the name of the space to be entered. However, since the same messaging server may be used, a prefix is used to identify them.
var groupName = $"Multiplay#{appState.Space.SpaceName}";
var messagingJoinConfig = new MessagingJoiningConfig(groupName);
var multiplayJoiningConfig = new MultiplayJoiningConfig(messagingJoinConfig);
try
{
await multiplayClient.JoinAsync(multiplayJoiningConfig);
}
catch (ConnectionException)
{
appState.Notify(assetHelper.MessageConfig.MultiplayUnexpectedDisconnectedMessage);
}
var groupName = $"TextChat#{appState.Space.SpaceName}";
var joiningConfig = new MessagingJoiningConfig(groupName);
try
{
await messagingClient.JoinAsync(joiningConfig);
}
catch (ConnectionException)
{
appState.Notify(assetHelper.MessageConfig.TextChatUnexpectedDisconnectedMessage);
}
Per Group
Voice chat
P2P is used. The host creates a group using the group name the user enters, and the client joins the group using the group ID associated with the group name the user specifies.
try
{
if (appState.IsHost)
{
await peerClient.StartHostAsync(appState.GroupName);
}
else
{
await peerClient.StartClientAsync(appState.GroupId);
}
}
catch (HostNameAlreadyExistsException e)
{
if (Logger.IsDebug())
{
Logger.LogDebug(e.Message);
}
handleOnHostNameAlreadyExists.Invoke();
}
Synchronization of user operations (space transitions, reactions, etc.)
Synchronization of user operations uses P2P text chat. The following is the process flow when synchronizing user operations.
For reference, here are the steps for adding a new user operation synchronization processing.
Create a class to store the information to be transmitted.
cf:
SpaceTransitionMessageContent.cs
public struct SpaceTransitionMessageContent : IMessageContent
{
public readonly StageName StageName => stageName;
[SerializeField, SuppressMessage("Usage", "CC0052")] private StageName stageName;
public SpaceTransitionMessageContent(StageName stageName)
=> this.stageName = stageName;
}
Add processing so that information is sent to
AppState
when user operation is detected.cf:
SpaceControlPresenter.cs
protected override void Initialize
(
StageNavigator<StageName, SceneName> stageNavigator,
AppState appState,
CompositeDisposable sceneDisposables
)
{
// omit
spaceControlView.OnGoButtonClicked
.Subscribe(_ =>
appState.SendMessage(
new Message(
MessageId.SpaceTransition,
new SpaceTransitionMessageContent(appState.Space.StageName))))
.AddTo(sceneDisposables);
// omit
}
Write the processing to be executed when receiving information to be synchronized from other clients.
cf:
SpaceControlPresenter.cs
protected override void Initialize
(
StageNavigator<StageName, SceneName> stageNavigator,
AppState appState,
CompositeDisposable sceneDisposables
)
{
// omit
appState.OnMessageReceived
.Where(message => message.MessageId == MessageId.SpaceTransition)
.Subscribe(message => ChangeSpace(message, appState, stageNavigator))
.AddTo(sceneDisposables);
// omit
}
private void ChangeSpace(Message message, AppState appState, StageNavigator<StageName, SceneName> stageNavigator)
{
var content = (SpaceTransitionMessageContent)message.Content;
var nextSpace = assetHelper.SpaceConfig.Spaces.First(space => space.StageName == content.StageName);
appState.SetSpace(nextSpace);
spaceControlView.SetSpaceDropdownValue(appState.Space.SpaceName);
SwitchSpace(appState, stageNavigator);
}
private void SwitchSpace(AppState appState, StageNavigator<StageName, SceneName> stageNavigator)
{
var landscapeType = appState.Space.LandscapeType;
if (landscapeType == LandscapeType.None)
{
assetHelper.DownloadSpaceAsset(appState.SpaceName, appState.Space.StageName);
}
else
{
appState.SetSpace(appState.Space);
stageNavigator.ReplaceAsync(appState.Space.StageName).Forget();
}
}
Build
Since the build configuration is included in the repository, only the following settings need to be changed to build for production.
- Add the
HOLIDAY_PROD
symbol toPlayer Settings > Other Settings > Script Compilation
.
Applications are built on WebGL
.
Application usage visualization
Specification
The following log data can be collected and visualized for application usage visualization.
Common items
- Client ID
- An ID that identifies the client. A UUID is generated for each application and is used as the identifier for the client.
- The client ID is stored in PlayerPrefs. The client ID is generated only if it does not exist in PlayerPrefs.
- Usage ID
- An ID that identifies the usage status.
- Give a unique name for each sending timing.
- Stage name
- Stage name representing the stage from which the usage was obtained.
User usage
- First use
- Usage ID: FirstUse
- Send timing: Immediately after generating the client ID
- Sent data: OS, Device model, Device type, Device ID, Processor type
- Analytical uses: Number of unique users, devices used, etc
- Stage usage
- Usage ID: StageUsage
- Send timing: Just before exiting the stage and just before the application is exited
- Sent data: Stay time, number of text chats sent
- Analytical uses: Stay time on stage, number of text chats sent, etc
Resource usage
- Usage ID: ResourceUsage
- Send timing: Fixed interval (specified by settings, e.g., 10 seconds)
- Sent data: Memory size, used memory size, heap size, used heap size
- Analytical uses: Memory usage, etc
Error status
- Usage ID: ErrorStatus
- Send timing: Just before the error log is output
- Sent data: Error message, error type, exception message, stack trace (specified by settings, e.g., 500 characters)
- Analytical uses: Number of errors, etc
System structure
The following applications are used for log collecting and data visualization (dashboards).
These applications are run using Docker Compose. Please refer to Holiday's README for instructions on running these applications and creating dashboards.
Application design
The following classes are used to send log data from the application to Loki
AppUsageManager
- AppUsageManager is created in the App scene and starts collecting logs.
- AppUsageManager uses AppUsageLogWriter to log JSON with the INFO level/AppUsage category.
IAppUsageCollector
- The class that implements IAppUsageCollector controls the creation and timing of sent data.
- To avoid interfering with the application's original subscription processing, IObservable and Common's Hook are used to control the timing of sending.
AppUsageEmitter
- AppUsageEmitter provides IObservable representing the required timing for sending, such as first use, application exit, etc.
AppUsageLogWriter
- AppUsageLogWriter processes according to log category and log level.
- If the log category is AppUsage, it sends the log data to Loki as is.
- If the log level is Error, it creates an ErrorStatus and sends it to Loki.
- Logs other than the above are delegated to UnityDebugLogWriter.
AppUsageUtils
- AppUsageLogUtils provides processing common to both AppUsageManager and AppUsageLogWriter.
AppUsageBase
- AppUsageBase defines common items and AppUsageBase subclasses define items based on usage.
- Create JSON from AppUsageBase using JsonUtility.
AppUsageConfig
- AppUsageConfig provides configuration information such as URLs to Loki, timeouts, and resource usage collection intervals.
- When the Enable field of AppUsageConfig is turned OFF, all processing related to log data sending, such as creating sent data and controlling the timing of sending, will not be executed.
If you want to change log data sending, consider changing AppUsageManager, the class that implements IAppUsageCollector, and AppUsageBase (or subclasses).