This is the multi-page printable view of this section. Click here to print.
Documentation
- 1: Overcooked! 2 Modding
- 1.1: Simple Modding
- 1.2: Advanced Modding
- 2: Overcooked! 2 TAS
- 3: Overcooked! 2 Tech Docs
- 3.1: The Entity System
- 3.2: Ingredient Bugs
- 3.3: Kitchen Flow Controller
- 3.4: Delivering Plates
- 3.5: Controls
- 3.6: Cooking
- 3.7: Throwing
- 3.8: Plate Stacks
- 3.9: Cannon
- 4: Penguin Chess Engine
1 - Overcooked! 2 Modding
Simple Modding
Simple mods require only C# code editing, and can be done easily with dnSpy, optionally BepInEx.
See for example my Leaderboard Mod on GitHub (the screenshot above).
Advanced Modding
More sophisticated mods also require adding new Unity assets.
See for example my Breakfast Mod (a gameplay video with Overtyr, greeny, and BunnyBrittany).
1.1 - Simple Modding
Disclaimer
Overcooked is a trademark of Team17. By no means is any content on this site intended to infrige on any rights of Team17. Please contact me if you are concerned.Basics of the Game Structure
Overcooked! 2 is written in Unity version 2017.4.8 with no IL2CPP compilation.
The game files contain these major parts that we care about:
Overcooked2.exe
- the main executableOvercooked2_Data/StreamingAssets/Windows/
- all the Unity assets (models, textures, sounds, etc.)Overcooked2_Data/Managed/Assembly-CSharp.dll
- all the C# code used by the assets
For simple modding, we only need to edit the Assembly-CSharp.dll
.
Editing C# Code
First, we need to inspect and understand the C# code we care about. dnSpy is a great tool for this.
To edit the code, we can either
- Use dnSpy to directly edit the dll. This is the simplest method; or
- Use BepInEx to dynamically inject modified code at runtime. This requires a bit more setup, but is highly recommended for more complex code editing, because it allows you to manage your code with version control, and makes the code injection easily reproducible.
There is plenty of tutorial and documentation on how to use dnSpy and BepInEx, so I won’t go into details here.
How do I …?
The Overcooked codebase is fairly complex, so it’s not always easy to find where certain game logic is. A fair amount of investigation is needed in general to make complex mods.
Refer to the Overcooked! 2 Tech Docs for some documentation about the trickest parts of the game code.
The rest is up to your imagination and determination. Good luck!
1.2 - Advanced Modding
Disclaimer
Overcooked is a trademark of Team17. By no means is any content on this site intended to infrige on any rights of Team17. Please contact me if you are concerned.Limitations of C# Editing
There is a lot you can do with just editing C#, but there are also strong limitations:
- It’s impossible to add new 3D models.
- It’s hard or impossible to add new 2D content.
- It’s very difficult to modify parameters that are encoded in Unity assets rather than in the code.
- For example, the list of recipes that are available for a level is not encoded in
Assembly-CSharp.dll
, but in assets that are specific to the level. It’s possible to change this in the code, but it’s very awkward and requires creative trickery.
- For example, the list of recipes that are available for a level is not encoded in
Difficulty of Unity Asset Editing
You may have heard of tools like AssetStudio or UABE that can extract and edit Unity assets. Unfortunately, none of these work well for this game. There are a few reasons:
- These tools mostly specialize in replacing graphical or audio assets, but most Overcooked mods would
also require editing
ScriptableObject
objects (essentially, simple serialized objects without graphics). That makes these tools rather awkward to use. - The game uses streaming assets, which are newer than the old Unity asset formats supported by these outdated tools. I wasn’t able to get any of these to work even for basic asset replacement.
Using Unity Itself to Add Assets
The fact that the game uses StreamingAssets is actually very helpful to us. It means that we can compile an asset bundle using the official Unity Editor, and then just load it at runtime.
This is so clean - we don’t need to modify any of the existing assets at all. If we need to replace an existing asset, we can always just edit the code to modify some variable to point to our new asset.
However, there is a serious challenge. It’s not very useful to just add fresh new assets. These assets need references to existing assets. This becomes a real problem when creating these assets in Unity, since we don’t have the existing assets to reference at all.
It would be nice if we could load existing assets into the Unity Editor, but that’s not how it works. The Unity compilation process is lossy so that it’s not possible to revert the compiled assets back to editable assets.
A Creative Solution
We’ll first use uTinyRipper to reverse-engineer the game into a Unity project. This is far from perfect - there are numerous problems with the generated project, but it will give us the existing assets just so that they can be referenced. If we create a new asset this way and compile it into a new asset bundle (without the existing assets), when loading it at runtime, the new asset will reference the real existing asset in the game (because they have the same object ID as the reverse-engineered assets).
This is the basic idea, but there are a few more major issues to work out:
- The generated project is not compilable, and Unity will refuse to export asset bundles unless we make it compilable.
- uTinyRipper cannot decompile shaders, so any new asset we create has defective materials that must be fixed at runtime.
- We cannot easily add new C#-backed components that are referenced by new assets, because there is a circular dependency problem.
Note
TODO: More details about these issues.Discord
Use this link to join my Overcooked Modding and TAS Discord Server!
2 - Overcooked! 2 TAS
Showcase (2023)
Showcase (2020)
What is a TAS?
TAS stands for Tool-Assisted Speedrun, but in the context of Overcooked, it means a high-score gameplay with the aid of tools.
This game is challenging for a TAS because it is a multiplyer game. Simply slowing down the game, for example, would not give much of an advantage, and it could only be done with local co-op. Online gameplays obviously cannot be slowed down.
For that reason, the only feasible way to do a TAS for this game is to automate the entire gameplay.
Automation Strategies
There are some quite distinct approaches:
- Arrange raw keystrokes. This is the simplest but perhaps most annoying approach. The idea is to write a script to send keystrokes at precisely the right time. It’s annoying because it’s very difficult to get the timing right, and it’s also very difficult to test and debug. That being said, the legendary player G_U_A has managed to do this for Carnival of Chaos 3-4.
- Arrange high-level actions. This is the approach I took. A script is still written, but the script contains higher-level actions such as “pick up this item here”, “chop this item until done”, etc. This requires interpreting the action in the context of the game’s state, and turning it into a sequence of raw game inputs. It’s easier to write, but still suffers from the problem that testing the script takes a long time.
- Write an AI. Another idea is to write an AI to automatically play the game without scripts. I’m not sure how to do this especially since the game is so complicated, but it would be amazing to see.
Solving the Testing Problem
To test any script, we need to run it from the beginning of a level. We can speed up the gameplay with a well known trick Time.captureFramerate
, but still, it is an \(O(N^2)\)
time endeavor to arrange a level, so to speak. It would be nice if we can test a script from any point in the level, so we can incrementally
write the script and test it as we go.
Offline Simulation (2020)
In 2020, my approach was to write an offline simulator that takes the game state and a list of actions and runs these actions against the game state without using the actual game. The simulator needed to be reasonably accurate compared to the real game, but it didn’t need to be perfect. A special tool was made to arrange the chefs’ actions and simulate it offline at the same time. Once in a while, we can connect to the real game and simulate it from the beginning, to make sure that the script does work in the game. We can then continue from where the real game left off and continue arranging with offline simulation.
The following video demonstrates this offline simulation implementation used for the 2020 TAS:
One big limitation with the offline simulation approach is that behavior that relies on physics (e.g. collision) is very difficult to simulate accurately. This makes it impractical to arrange a script for any level that requires throwing many items onto the floor.
Online Frame Warping (2023)
In 2023, I rebooted my interest in Overcooked TAS and decided to implement a new approach: make the game instantly “warp” to any previously recorded frame.
With this technology, a script can be tested from any point of the level, and all mechanics of the game are supported, because the real game is used. This gets around the limitations of the offline simulation approach, most importantly, physics are simulated accurately.
This is extremely challenging. The game is not built for suddenly changing the game state (unlike some other games where it’s possible to join a game in the middle), and although I’m doing TAS as a local co-op, the game’s architecture is written in a way that local games also use the online networking code. So the game may be in the middle of synchronizing some changes from server to client while I want to issue a warping command to suddenly transition to a completely different state.
Nonetheless, with enough effort I managed to implement this. Here is a video demonstrating the technology:
And here’s a more complete video explaining how the 2023 TAS was done:
Source Code
The project is currently in development at https://github.com/hpmv/overcooked-supercharged.
Discord
Join my Discord server if you would like to use the TAS tool and/or contribute to the project!
3 - Overcooked! 2 Tech Docs
See the docs in this section for a pretty incomplete coverage of the game’s code.
It mentions “warping” quite a bit, which refers to the technique used for TAS.
Disclaimer
Overcooked is a trademark of Team17. By no means is any content on this site intended to infrige on any rights of Team17. Please contact me if you are concerned.3.1 - The Entity System
Basics of Multiplayer
The game is written with multiplayer in mind, and even single player gameplay uses the same stack as the multiplayer scenario, just with a single client.
The host of a game is always the server, and all players in the game (including the host) are clients.
Generally speaking, the clients are only responsible for rendering and input, whereas the server is responsible for the game logic. For example, when a player grabs an tomato from a crate, its client sends a message to the server that “I will pickup something from the crate”, and the server decides to spawn a tomato and put it in the chef’s hands, and synchronizes that information to the clients. Even if the player is the host, it goes through the same code path, except the networking part just calls local code.
The physics simulation is somewhat of an exception. Both the server and the client run physics simulation. I don’t yet understand how that works.
Basics of the entity system
Each object during the gameplay that requires multiplayer synchronization has an entity ID. The entity IDs
are agreed upon at all times between the server and the clients. The bidirectional mapping between entity IDs
and their corresponding GameObjects are kept by EntitySerialisationRegistry
as static fields. The registry
also keeps track of some other properties.
- The staticness of the fields are relied upon by the deserialization code. When receiving a message over
the network references an object by its the entity ID, the message is deserialized directly into a
corresponding GameObject, by accessing
EntitySerialisationRegistry
statically. This is a questionable design decision, but in the face of Unity (which uses static objects all over the place), it doesn’t stand out.
Synchronizing Entities
Initially, when the level is first launched, the starting objects (such as counters, mixers, plates, chefs, etc.) are assigned entity IDs by iterating through the objects:
- See
EntitySerialisationRegistry.SetupSynchronisation
which then callsLinkAllEntitiesToSynchronisationScripts()
. - This assignment is done independently by both the server and all the (non-server) clients, and the agreement of the IDs depends on the iteration order of the game objects in the scene. This is presumably deterministic.
- The initial state of all the initial objects are not synchronized over the network; rather, the server and clients just assume they all have identical contents in the Unity scene.
Each synchronized entity (i.e. GameObject) can contain multiple components, each of which may provide its own synchronization logic:
- Each component has a base type, a server synchronizer, and a client synchronizer.
- For example, a cutting board typically contains components like
Workstation
andAttachStation
. TheWorkstation
component corresponds to the functionality of a cutting board, whereasAttachStation
provides the ability to place anything on the counter (such as a plate). The two components interact: if the attached object is aWorkableItem
, then theWorkstation
may be used to “work on” the item, which is the game’s way of saying, chopping it. - In this example, when
SetupSynchronisation
is called, the server will attachServerWorkstation
andServerAttachStation
to the same GameObject. A client (including the host’s) will attachClientWorkstation
andClientAttachStation
. - The pair of
ServerWorkstation
andClientWorkstation
work with each other to synchronize the state for just that aspect of the cutting board object. - The synchronization of each type of component uses a different entity message. For
Workstation
the message type isWorkstationMessage
. The message contains all states represented by this component.
Spawning an entity
An entity is spawned by specifying a spawner entity and the prefab to be spawned.
- On the server side this is done via
NetworkUtils.ServerSpawnPrefab(GameObject spawner, GameObject prefab)
.- For example,
ServerPlateReturnStation.CreateStack
callsNetworkUtils.ServerSpawnPrefab(this.gameObject, this.m_returnStation.m_stackPrefab)
, the latter being the prefab for a dirty plate stack.
- For example,
- On initialization, any object that can spawn something calls
NetworkUtils.RegisterSpawnablePrefab
. This will add aSpawnableEntityCollection
onto the GameObject if it’s not already there, and add the prefab to the list of spawnables. InsideNetworkUtils.ServerSpawnPrefab
, theSpawnableEntityCollection
is requested from the spawner GameObject via the interfaceINetworkEntitySpawner
(whose only implementation isSpawnableEntityCollection
). - When calling
NetworkUtils.ServerSpawnPrefab
, the spawned object is automatically registered as a new entity in theEntitySerialisationRegistry
, and it automatically callsEntitySerialisationRegistry.StartSynchronisingEntry
on the resulting GameObject.- This happens before calling
ServerMessenger.SpawnEntity
to notify clients about the spawning of the entity. This implies that StartSynchronisingEntry should not immediately emit any synchronization messages, or else the message will just be dropped at the client side. - The function also checks if the spawned object is a
PhysicalAttachment
, which generally means something that has a container object that has aRigidBody
. If so, it additionally registers the container object as a separate entity, and instead of callingServerMessenger.SpawnEntity
, callsServerMessenger.SpawnPhysicalAttachment
instead - this is a separate network message type. This message includes both entity IDs.- See the client side at
ClientSynchronisationReceiver.OnSpawnPhysicalAttachmentMessageReceived
.
- See the client side at
- This happens before calling
Messaging and Serialization
All the message types and entity message types are registered in MultiplayerController.Awake
.
When are Entities Synchronized
Most entities are synchronized when the corresponding server component calls SendServerData
, which is then
processed by the client side when it arrives.
Some entities are synchronized automatically by the entity system, if they override GetServerUpdate
. Periodically (once every few frames), the ServerSynchronisationScheduler.SynchroniseList
function will
send an EntitySynchronisationMessage
to synchronize all such entities to the clients.
These automatically synchronized entities are ServerWorldObjectSynchroniser
(for physics synchronization), ServerCookingHandler
, ServerIngredientContainer
, and ServerMixingHandler
.
3.2 - Ingredient Bugs
Mega Burger
Some people found this funny bug where you can make a mega-burger:
Example video: https://www.bilibili.com/video/BV1ZX4y157Ra/
![](/megaburger.png)
Let’s see what happened.
ServerPreparationContainer
is the primary component of a burger bun; here’s the code that is invoked when trying to
place something onto it:
public bool CanHandlePlacement(ICarrier _carrier, Vector2 _directionXZ, PlacementContext _context)
{
// Get the thing the chef is holding (a plate of ingredients, in this case).
GameObject gameObject = _carrier.InspectCarriedItem();
// Gets the definition of the plate's contents. In this case it would be 3 separate items
// (cooked meat, cooked meat, pineapple). See below.
AssembledDefinitionNode[] orderDefinitionOfCarriedItem = this.GetOrderDefinitionOfCarriedItem(gameObject);
// This is the plate's ServerIngredientContainer component.
ServerIngredientContainer component = gameObject.GetComponent<ServerIngredientContainer>();
// It indeed is a plate.
Plate component2 = gameObject.GetComponent<Plate>();
// See below. Passes if individual ingredients are each eligible, and also container is not
// overfilled in capacity.
if (!this.CanAddOrderContents(orderDefinitionOfCarriedItem))
{
// Tray is the rectangular tray, not the case here.
Tray component3 = gameObject.GetComponent<Tray>();
if (component3 != null)
{
return true;
}
// If the check above fails, and the thing is not a plate, or its ingredients can't
// individually be inserted, then fail.
if (!(component2 != null) || !(component != null) || !this.CanTransferToContainer(component))
{
return false;
}
}
// Finally check if the thing we're inserting is a plate (true), or the two plating steps are the same (plating steps are like,
// cup vs plate vs tray).
return !(component2 != null) || !(this.m_preparationContainer.m_ingredientOrderNode.m_platingStep != component2.m_platingStep);
// To summarize, if the thing we're transferring from is a plate, we succeed if either:
// - The plates' contents are individually eligible for the target, and the target wouldn't be overfilled; OR
// - the plate's ServerIngredientContainer's individual elements are transferrable to the target (but without checking
// target's capacity!)
}
// ServerPreparationContainer::GetOrderDefinitionOfCarriedItem
private AssembledDefinitionNode[] GetOrderDefinitionOfCarriedItem(GameObject _carriedItem)
{
IBaseCookable cookingHandler = null;
// A plate is not cookable, so doesn't apply here.
ServerCookableContainer component = _carriedItem.GetComponent<ServerCookableContainer>();
if (component != null)
{
cookingHandler = component.GetCookingHandler();
}
return this.m_preparationContainer.GetOrderDefinitionOfCarriedItem(_carriedItem, this.m_itemContainer, cookingHandler);
}
// PreparationContainer::GetOrderDefinitionOfCarriedItem
public AssembledDefinitionNode[] GetOrderDefinitionOfCarriedItem(GameObject _carriedItem, IIngredientContents _itemContainer, IBaseCookable _cookingHandler)
{
AssembledDefinitionNode[] result = null;
if (_carriedItem.GetComponent<CookableContainer>() != null)
{
ClientIngredientContainer component = _carriedItem.GetComponent<ClientIngredientContainer>();
IContainerTransferBehaviour containerTransferBehaviour = _carriedItem.RequireInterface<IContainerTransferBehaviour>();
if (component.HasContents() && containerTransferBehaviour.CanTransferToContainer(_itemContainer))
{
CookableContainer component2 = _carriedItem.GetComponent<CookableContainer>();
ClientMixableContainer component3 = _carriedItem.GetComponent<ClientMixableContainer>();
AssembledDefinitionNode cookableMixableContents = null;
bool isMixed = false;
if (component3 != null)
{
cookableMixableContents = component3.GetOrderComposition();
isMixed = component3.GetMixingHandler().IsMixed();
}
CookedCompositeAssembledNode cookedCompositeAssembledNode = component2.GetOrderComposition(_itemContainer, _cookingHandler, cookableMixableContents, isMixed) as CookedCompositeAssembledNode;
cookedCompositeAssembledNode.m_composition = new AssembledDefinitionNode[]
{
component.GetContentsElement(0)
};
result = new AssembledDefinitionNode[]
{
cookedCompositeAssembledNode
};
}
}
else if (_carriedItem.GetComponent<IngredientPropertiesComponent>() != null)
{
IngredientPropertiesComponent component4 = _carriedItem.GetComponent<IngredientPropertiesComponent>();
result = new AssembledDefinitionNode[]
{
component4.GetOrderComposition()
};
}
// This case applies.
else if (_carriedItem.GetComponent<Plate>() != null)
{
ClientIngredientContainer component5 = _carriedItem.GetComponent<ClientIngredientContainer>();
result = component5.GetContents();
}
return result;
}
// ClientIngredientContainer::GetContents. Why Client and not Server? I have no idea.
public AssembledDefinitionNode[] GetContents()
{
return this.m_contents.ToArray();
}
// ServerPreparationContainer::CanAddOrderContents - can we add these things into the bun?
public bool CanAddOrderContents(AssembledDefinitionNode[] _contents)
{
if (_contents != null)
{
foreach (AssembledDefinitionNode toAdd in _contents)
{
// Check each individual ingredient.
if (!this.CanAddIngredient(toAdd))
{
return false;
}
}
// Then check the whole container.
// This does nothing more than checking the capacity.
return this.m_itemContainer.CanTakeContents(_contents);
}
return false;
}
// ServerPreparationContainer::CanAddIngredient
protected virtual bool CanAddIngredient(AssembledDefinitionNode _toAdd)
{
CompositeAssembledNode asOrderComposite = this.GetAsOrderComposite();
return asOrderComposite.CanAddOrderNode(_toAdd, true);
}
// CompositeAssembledNode::CanAddOrderNode
// Basically checks that each ingredient is allowable for this container,
// and that we don't already have too many (e.g. can't insert two lettuces).
public bool CanAddOrderNode(AssembledDefinitionNode _toAdd, bool _raw = false)
{
if (!_raw && this.m_freeObject != null && this.m_freeObject.RequestInterface<IHandleOrderModification>() != null)
{
IHandleOrderModification handleOrderModification = this.m_freeObject.RequestInterface<IHandleOrderModification>();
return handleOrderModification.CanAddOrderContents(new AssembledDefinitionNode[]
{
_toAdd
});
}
int num = 0;
foreach (AssembledDefinitionNode node in this.m_composition)
{
if (AssembledDefinitionNode.Matching(node, _toAdd))
{
num++;
}
}
Predicate<OrderContentRestriction> match = (OrderContentRestriction _specifier) => AssembledDefinitionNode.Matching(_specifier.m_content, _toAdd);
OrderContentRestriction orderContentRestriction = this.m_permittedEntries.Find(match);
return orderContentRestriction != null && num < orderContentRestriction.m_amountAllowed && !this.CompositionContainsRestrictedNode(_toAdd, orderContentRestriction);
}
Also:
// ServerPreparationContainer::CanTransferToContainer. It basically checks every component of
// the container's contents.
public virtual bool CanTransferToContainer(IIngredientContents _container)
{
if (_container.HasContents())
{
for (int i = 0; i < _container.GetContentsCount(); i++)
{
AssembledDefinitionNode contentsElement = _container.GetContentsElement(i);
if (!this.CanAddIngredient(contentsElement))
{
return false;
}
}
}
return true;
}
3.3 - Kitchen Flow Controller
The Kitchen Flow Controller (ServerKitchenFlowControllerBase
and ClientKitchenFlowControllerBase
)
is responsible for keeping track of:
- the list of orders currently on the screen, what each order is and their timer
- when to spawn the next order, and the RNG state that should be used to derive the next order
- the score and tip streak for the “team” (which there can be two of, in case of Versus mode)
- managing the PlateReturnController (see plates.md)
Maintaining the order list (Server side)
The order list is maintained in an inner structure called ServerOrderControllerBase
. It maintains:
RoundDataBase m_roundData
which is the implementation used to generate new ordersRoundInstanceDataBase m_roundInstanceData
, which is the current RNG state of them_roundData
m_nextOrderID
, starting at 1, responsible for assigning anOrderID
for the next orderOrderID
is used to identify orders internally – since the active orders list shifts around.
List<ServerOrderData> m_activeOrders
which is the list of currently active ordersfloat m_timerUntilOrder
, the amount of time until the next order should be automatically added (only relevant if there are currently 2, 3, or 4 orders)m_comboIndex
, which is the index of the last delivered order.
There are also a couple of other fields that are not quite relevant.
The ServerOrderData
is a serializable class that maintains:
- The
OrderID
- The
RecipeList.Entry
which is a reference to one element in the level’sRecipeList
float Lifetime
, the time allowed for the orderfloat Remaining
, the remaining time allowed for the order before it expires
In the ServerOrderControllerBase::Update()
function:
- The remaining time of each order is decreased by the time elapsed; if the remaining time is
<= 0
then the timeout callback is invoked; - The
m_timerUntilOrder
is decreased by the time elapsed - If there are fewer than 5 orders and it’s time to add a new order, or if there are fewer
than 2 orders, generate a new order, invoke the order added callback, and reset
m_timerUntilOrder
.
Now coming to ServerTeamMonitor
, which hosts the ServerOrderControllerBase
as its field; it also has
two other fields, TeamMonitor
and TeamMonitor.TeamScoreStats
. The former hosts nothing but the
RecipeFlowGUI
, which we’ll describe when talking about the client side later. The latter hosts the
scores of the team - which we discuss in the scoring section.
ServerTeamMonitor::OnFoodDelivered
function is called when a dish is delivered by the player:
public virtual bool OnFoodDelivered(AssembledDefinitionNode _definition, PlatingStepData _plateType, out OrderID o_orderID, out RecipeList.Entry o_entry, out float o_timePropRemainingPercentage, out bool o_wasCombo)
{
o_orderID = default(OrderID);
o_entry = null;
o_timePropRemainingPercentage = 0f;
o_wasCombo = false;
// This matches the recipe against the orders. It'll return the matching order with the
// least amount of time remaining. If there are no matching orders it returns false.
bool flag = this.m_ordersController.FindBestOrderForRecipe(_definition, _plateType, out o_orderID, out o_timePropRemainingPercentage);
if (flag)
{
// Calculates whether the order was a combo, as this affects scoring.
o_entry = this.m_ordersController.GetRecipe(o_orderID);
bool restart = this.m_score.TotalCombo == 0;
o_wasCombo = this.m_ordersController.IsComboOrder(o_orderID, restart);
// Removes the order from the list of orders.
this.m_ordersController.RemoveOrder(o_orderID);
return true;
}
return false;
}
Now coming back to the root struct, ServerKitchenFlowControllerBase
, which hosts a collection of
ServerTeamMonitor
s; for simplicity we only look at co-op games, i.e. one team.
OnOrderAdded
is called when theServerOrderControllerBase
invokes the order added callback. Its only task here is to callSendOrderAdded
to send this as aKitchenFlowMessage
to the client side.OnOrderExpired
is similar, and invokesSendOrderExpired
to send to the client side. Additionally it updates a few fields such as resetting the combo on the team’s score struct, and resets the order’s timer.- When sending the data over to the client side, the data also includes the complete updated score data.
OnFoodDelivered
is called externally. It calls theServerTeamMonitor
’sOnFoodDelivered
and- if successful, calls
OnSuccessfulDelivery
; this computes how to affect the team’s score stats and then callsSendDeliverySuccess
to send it to the client. The message once again includes the full score data. - if failing, calls
OnFailedDelivery
, which resets the combo in the score struct and similarly callsSendDeliveryFailed
to share it with the client.
- if successful, calls
- Note that there’s also a way to send only the score to the client, but this is only used when cheating to skip a level with certain number of stars.
These OnXXX
Methods are actually overridden by the concrete implementation ServerCampaignFlowController
(or ServerCompetitiveFlowController
, which we don’t cover here). The additional logic controls minor things
like updating the timer suppressor when the first dish is delivered successfully.
Warping on server side
The KitchenFlowMessage
can allow us to maintain on the controller side:
- For
ServerOrderControllerBase
:- The list of active orders, by incremental computation (deletion is done by successful delivery)
- The next order ID, which should be the added order’s ID + 1
- The combo index, which can be derived from what was delivered
- The
m_timerUntilOrder
timer, by decrementing per frame and resetting upon adding orders - !!! The
RoundInstanceDataBase
is not recoverable as is, so would need a custom implementation
- For each order:
- The whole
ServerOrderData
, which comes straight from deserialization; - except the
Remaining
time is kept by decrementing per frame
- The whole
- For the
ServerTeamMonitor
, theTeamScoreStats
struct is wholly replaced each time by deserialization. - For the
ServerKitchenFlowControllerBase
, there aren’t any meaningful fields to warp.
Maintaining the order list (client side)
The client side mostly mirrors the server side but is more complicated because it additionally needs to maintain the GUI rendering of the order list.
In ClientKitchenFlowControllerBase
, we maintain:
- (through a subclass) a
ClientTeamMonitor
, which contains:- a
TeamMonitor
, same asServerTeamMonitor
- a
ClientOrderControllerBase
- a
TeamMonitor.TeamScoreStats
- a
- A
DataStore m_dataStore
; this seems to be an in-memory transient key-value store used for notifying UI rendering logic to update UI elements. OrderDefinitionNode[] m_cachedRecipeList
,AssembledDefinitionNode[] m_cachedAssembledRecipes
, andCookingStepData[] m_cachedCookingStepList
. They… don’t seem to actually do anything.
The events from the server are handled as:
protected virtual void OnSuccessfulDelivery(TeamID _teamID, OrderID _orderID, float _timePropRemainingPercentage, int _tip, bool _wasCombo, ClientPlateStation _station)
{
GameUtils.TriggerAudio(GameOneShotAudioTag.SuccessfulDelivery, base.gameObject.layer);
ClientTeamMonitor monitorForTeam = this.GetMonitorForTeam(_teamID);
int uID = monitorForTeam.OrdersController.GetRecipe(_orderID).m_order.m_uID;
// See ClientOrderControllerBase.
monitorForTeam.OrdersController.OnFoodDelivered(true, _orderID);
// Writes into the DataStore with the key "score.team" to update UI.
this.UpdateScoreUI(_teamID);
// This is to keep track of level objectives which don't seem to be used in the game?
if (this.m_onMealDelivered != null)
{
this.m_onMealDelivered(uID, _wasCombo);
}
// Achievement stuff.
OvercookedAchievementManager overcookedAchievementManager = GameUtils.RequestManager<OvercookedAchievementManager>();
if (overcookedAchievementManager != null)
{
overcookedAchievementManager.IncStat(1, 1f, ControlPadInput.PadNum.One);
overcookedAchievementManager.AddIDStat(22, uID, ControlPadInput.PadNum.One);
overcookedAchievementManager.AddIDStat(100, uID, ControlPadInput.PadNum.One);
overcookedAchievementManager.AddIDStat(500, uID, ControlPadInput.PadNum.One);
overcookedAchievementManager.AddIDStat(801, uID, ControlPadInput.PadNum.One);
}
}
protected virtual void OnFailedDelivery(TeamID _teamID, OrderID _orderID)
{
ClientTeamMonitor monitorForTeam = this.GetMonitorForTeam(_teamID);
monitorForTeam.OrdersController.OnFoodDelivered(false, _orderID);
this.UpdateScoreUI(_teamID);
// Also for level objectives which aren't useful.
if (this.m_onFailedDelivery != null)
{
this.m_onFailedDelivery();
}
}
protected virtual void OnOrderAdded(TeamID _teamID, Serialisable _orderData)
{
ClientTeamMonitor monitorForTeam = this.GetMonitorForTeam(_teamID);
monitorForTeam.OrdersController.AddNewOrder(_orderData);
this.UpdateScoreUI(_teamID);
}
protected virtual void OnOrderExpired(TeamID _teamID, OrderID _orderID)
{
ClientTeamMonitor monitorForTeam = this.GetMonitorForTeam(_teamID);
monitorForTeam.OrdersController.OnOrderExpired(_orderID);
this.UpdateScoreUI(_teamID);
}
Note that the actual concrete class ClientCampaignFlowController
overrides these methods to call
a few extra things in ClientCampaignMode
. This includes updating the score tip display (DataStore
key “score.tip”), and timer suppression update.
Into the ClientOrderControllerBase
,
public virtual void OnFoodDelivered(bool _success, OrderID _orderID)
{
if (_success)
{
ClientOrderControllerBase.ActiveOrder activeOrder = this.m_activeOrders.Find((ClientOrderControllerBase.ActiveOrder x) => x.ID == _orderID);
if (activeOrder != null)
{
// Tells the GUI to use an animation to remove the order item. It's not immediately removed
// from the rendered list but only after the animation is done.
this.m_gui.RemoveElement(activeOrder.UIToken, new RecipeSuccessAnimation());
}
// Mirroring of the server side's active order list.
this.m_activeOrders.RemoveAll((ClientOrderControllerBase.ActiveOrder x) => x.ID == _orderID);
}
else
{
for (int i = 0; i < this.m_activeOrders.Count; i++)
{
// On failure animate all items with the failure animation.
this.m_gui.PlayAnimationOnElement(this.m_activeOrders[i].UIToken, new RecipeFailureAnimation());
}
}
}
public virtual void AddNewOrder(Serialisable _data)
{
ServerOrderData data = (ServerOrderData)_data;
RecipeList.Entry entry = new RecipeList.Entry();
entry.Copy(data.RecipeListEntry);
// Adds the item to the GUI.
RecipeFlowGUI.ElementToken token = this.m_gui.AddElement(entry.m_order, data.Lifetime, this.m_expiredDoNothingCallback);
// Adds the item to the mirrored list of active orders.
ClientOrderControllerBase.ActiveOrder item = new ClientOrderControllerBase.ActiveOrder(data.ID, entry, token);
this.m_activeOrders.Add(item);
}
public virtual void OnOrderExpired(OrderID _orderID)
{
GameUtils.TriggerAudio(GameOneShotAudioTag.RecipeTimeOut, this.m_gui.gameObject.layer);
ClientOrderControllerBase.ActiveOrder activeOrder = this.m_activeOrders.Find((ClientOrderControllerBase.ActiveOrder x) => x.ID == _orderID);
if (activeOrder != null)
{
// Order expiration is only a GUI effect because on the client side we don't mirror
// the amount of time remaining (only graphically).
this.m_gui.PlayAnimationOnElement(activeOrder.UIToken, new RecipeFailureAnimation());
this.m_gui.ResetElementTimer(activeOrder.UIToken);
}
}
Now we go into the RecipeFlowGUI
class, which is responsible for rendering the order list.
It maintains:
int m_nextIndex
, used to number newly added recipe widgets (each order is one recipe widget).List<RecipeFlowGUI.RecipeWidgetData> m_widgets
, the list of active widgets.List<RecipeFlowGUI.RecipeWidgetData> m_dyingWidgets
, the list of widgets being deleted but still going through the deletion animation.- (not important)
List<RecipeFlowGUI.RecipeWidgetData> m_ordererWidgets
, which is used only as a local variable to merge sort them_widgets
andm_dyingWidgets
lists. - (not important)
bool[] m_occupiedTables
which simulates tables at a restaurant but is not actually displayed or used anywhere in the game. We still need to maintain it though because each order does have a table ID.
The actions in RecipeFlowGUI
are:
public RecipeFlowGUI.ElementToken AddElement(OrderDefinitionNode _data, float _timeLimit, VoidGeneric<RecipeFlowGUI.ElementToken> _expirationCallback)
{
int tableNumber = this.ClaimUnoccupiedTable(); // not important
// Sets up the UI element to render the recipe.
// When the UI element is first set up, there's an initial animation transition to slide the
// recipe into place.
GameObject obj = GameUtils.InstantiateUIController(this.m_recipeWidgetPrefab.gameObject, base.transform as RectTransform);
RecipeWidgetUIController recipeWidgetUIController = obj.RequireComponent<RecipeWidgetUIController>();
recipeWidgetUIController.SetupFromOrderDefinition(_data, tableNumber);
// Sets up the data structure to maintain the UI element.
RecipeFlowGUI.RecipeWidgetData recipeWidgetData = new RecipeFlowGUI.RecipeWidgetData(recipeWidgetUIController, 0f, this.m_nextIndex, _timeLimit, _expirationCallback);
this.m_nextIndex++;
// adds it to the active list.
this.m_widgets.Add(recipeWidgetData);
return new RecipeFlowGUI.ElementToken(recipeWidgetData);
}
public void RemoveElement(RecipeFlowGUI.ElementToken _token, WidgetAnimation _deathAnim = null)
{
for (int i = this.m_widgets.Count - 1; i >= 0; i--)
{
RecipeFlowGUI.RecipeWidgetData recipeWidgetData = this.m_widgets[i];
if (new RecipeFlowGUI.ElementToken(recipeWidgetData) == _token)
{
if (_deathAnim != null)
{
// If there's an animation, add it to the list of dying widgets and
// play the animation.
this.ReleaseTable(recipeWidgetData.m_widget.GetTableNumber());
recipeWidgetData.m_widget.PlayAnimation(_deathAnim);
this.m_dyingWidgets.Add(recipeWidgetData);
}
this.m_widgets.RemoveAt(i);
}
}
}
// Called in `ClientOrderControllerBase` to decrement each order's remaining time.
public void UpdateTimers(float _dt)
{
for (int i = 0; i < this.m_widgets.Count; i++)
{
float timePropRemaining = this.m_widgets[i].m_widget.GetTimePropRemaining();
float num = timePropRemaining;
num = Mathf.Max(num - _dt / this.m_widgets[i].m_timeLimit, 0f);
this.m_widgets[i].m_widget.SetTimePropRemaining(num);
if (timePropRemaining > 0f && num <= 0f)
{
// This doesn't seem to do anything.
this.m_widgets[i].m_expirationCallback(new RecipeFlowGUI.ElementToken(this.m_widgets[i]));
}
}
}
private void Update()
{
// Remove those dying widgets that finished their deletion animations.
this.m_dyingWidgets.RemoveAll(delegate(RecipeFlowGUI.RecipeWidgetData obj)
{
if (!obj.m_widget.IsPlayingAnimation())
{
UnityEngine.Object.Destroy(obj.m_widget.gameObject);
return true;
}
return false;
});
// Layouts the remaining widgets.
this.LayoutWidgets();
}
private void LayoutWidgets()
{
// Merge sorts the active and dying widgets.
this.FindAllWidgetsOrdered(ref this.m_ordererWidgets);
float distanceFromEndOfScreen = this.m_distanceFromEndOfScreen;
float distanceBetweenOrders = this.m_distanceBetweenOrders;
float num = distanceFromEndOfScreen - distanceBetweenOrders;
for (int i = 0; i < this.m_ordererWidgets.Count; i++)
{
RecipeWidgetUIController widget = this.m_ordererWidgets[i].m_widget;
float width = widget.GetBounds().width;
// There's nothing fancy going on here. The entry animation is done within the UI prefab.
// When removing a widget there's no animation for the remaining widgets to slide over.
RectTransformExtension rectTransformExtension = widget.gameObject.RequireComponent<RectTransformExtension>();
float num2 = num + distanceBetweenOrders;
rectTransformExtension.AnchorOffset = new Vector2(0f, 0f);
rectTransformExtension.PixelOffset = new Vector2(num2, 0f);
num = num2 + width;
}
}
Finally, the RecipeWidgetUIController
:
public void SetupFromOrderDefinition(OrderDefinitionNode _data, int _tableNumber)
{
this.m_recipeTree = _data.m_orderGuiDescription;
this.m_tableNumber = _tableNumber;
// Delays the preparation of the actual UI.
base.StartCoroutine(this.RefreshSubObjectsAtEndOfFrame());
if (T17InGameFlow.Instance != null)
{
T17InGameFlow.Instance.RegisterOnPauseMenuVisibilityChanged(new BaseMenuBehaviour.BaseMenuBehaviourEvent(this.OnPauseMenuVisibilityChange));
}
}
private IEnumerator RefreshSubObjectsAtEndOfFrame()
{
yield return new WaitForEndOfFrame();
base.RefreshSubElements();
yield break;
}
// From base class UISubElementContainer:
public void RefreshSubElements()
{
// Does some stuff, mostly calling OnCreateSubobjects.
this.EnsureImagesExist();
this.m_debugActivated = true;
this.RefreshSubObjectProperties();
}
And then the OnCreateSubobjects
function adds the animator, sets up the recipe tiles, etc.
Warping the client side
- Update the m_activeOrders in the
ClientOrderControllerBase
- just overwrite the entire list. - Very intrusively update the
RecipeFlowGUI
:- Overwrite the
m_nextIndex
- we can make it arbitrarily high, maybe just don’t reuse any IDs. - Overwrite
m_widgets
to be the currently active orders; reinitialize all of them, force set the timePropRemaining of each order- The
OnCreateSubobjects
callback need to pulled earlier. To do this, instead of invokingSetupFromOrderDefinition
, we will do that ourselves (it’s just the m_recipeTree and m_tableNumber), and then callRefreshSubElements
.
- The
- Overwrite
m_dyingWidgets
to be empty. - Overwrite
m_occupiedTables
to be consistent with whatever indexes assigned to our newly created orders.
- Overwrite the
- Completely replace the
ClientTeamMonitor
’s score struct and refresh DataStore “score.team” key.- Note, not “score.tip” key. That’s for showing up the tip amount above the serving station.
Scoring
public class TeamScoreStats : Serialisable {
// ...
public int TotalBaseScore;
public int TotalTipsScore;
public int TotalMultiplier;
public int TotalCombo;
public int TotalTimeExpireDeductions;
public bool ComboMaintained = true;
public int TotalSuccessfulDeliveries;
}
3.4 - Delivering Plates
ServerPlateStation
is the serving station.
- It keeps a list of
ServerPlateReturnStation
s which corresponds to the stations where dirty plates will spawn. There can be multiple because there may be multiple kinds of plates (cups and plates, e.g.). IKitchenOrderHandler m_orderHandler
handles the delivery of the order.- Implemented by
ServerKitchenFlowControllerBase
. TheOnFoodDelivered
calls itsm_plateReturnController.FoodDelivered
, which adds aPlatesPendingReturn
to its list of pending plates. - A
PlatesPendingReturn
specifies the return station, the timer, and thePlatingStepData
(identifying the kind of plate). PlatingStepData
is just anm_uID
plus a sprite. The actual prefab for the dirty plate is created by thePlateReturnStation
as a spawn.
- Implemented by
- Logic is triggered by
OnItemAdded
, registered on them_attachStation
(sibling component) viaRegisterOnItemAdded
.
3.5 - Controls
ClientPlayerControlsImpl_Default
Fields:
private PlayerControlsImpl_Default m_controlsImpl;
The associatedPlayerControlsImpl_Default
component of the same object.private ClientInteractable m_lastInteracted;
The last object interacted with. Set in BeginInteraction and cleared in EndInteraction. Seems to only be queried by code that affects animations?private ClientInteractable m_predictedInteracted;
Part of some super complicated interaction logic:private void Update_Interact(float _deltaTime, bool isUsePressed, bool justPressed) { // This is the highlighted object to be interacted. ClientInteractable highlighted = this.m_controls.CurrentInteractionObjects.m_interactable; // Object already being interacted bool alreadyInteracting = this.m_predictedInteracted != null; bool hasHighlight = highlighted != null; bool isCurrentInteractionSticky = alreadyInteracting && this.m_predictedInteracted.InteractionIsSticky(); if (isUsePressed && hasHighlight && !alreadyInteracting) { // If we aren't already interacting with something, and the interaction button is down (doesn't have to // be just pressed), and we have a highlighted object to interact with, then start the interaction // with that object. this.m_predictedInteracted = highlighted; ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.Interact, base.gameObject, highlighted); } else if (!hasHighlight && alreadyInteracting && isCurrentInteractionSticky) { // If current interaction is sticky (i.e. does not require pressing), but there is no longer a // currently highlighted object, then stop interacting. this.m_predictedInteracted = null; ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.Interact, base.gameObject, null); } else if (!isUsePressed && alreadyInteracting && !isCurrentInteractionSticky) { // If current interaction is not sticky (requires continuous pressing) but the key is // no longer down, then stop interacting. e.g. fire extingisher, washer jet. this.m_predictedInteracted = null; ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.Interact, base.gameObject, null); } else if (hasHighlight && highlighted != this.m_predictedInteracted) { // If the currently highlighted object is different from the currently interacted object, // stop interacting. this.m_predictedInteracted = null; ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.Interact, base.gameObject, null); } if (justPressed && highlighted != null) { // Independently of everything else, if we had *just* pressed the use button and we have a highlighted // object to use, trigger the interaction with that object. ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.TriggerInteract, base.gameObject, highlighted); } }
private Transform m_Transform;
Transform associated with the chef.private ClientInteractable m_sessionInteraction;
This is set while the chef is in an active “Session Interaction”, i.e. an interaction that makes the chef uncontrollable before it ends; e.g. operating the raft control, or being inside a cannon.private PlayerControls m_controls;
The non-synchronizing PlayerControls object.private PlayerControls.ControlSchemeData m_controlScheme;
Provides information for the control scheme, i.e. what keys are used to dash, interact, etc.private GameObject m_controlObject;
The chef.private CollisionRecorder m_collisionRecorder;
Records recent collisions, used to compute dash collision with another player.private PlayerIDProvider m_controlsPlayer;
Component on the chef that provides the ID of the player.private ICarrier m_iCarrier;
Component on the chef that deals with carrying objects. TODO: this is satisfied by both ServerPlayerAttachmentCarrier and ClientPlayerAttachmentCarrier.private IClientThrower m_iThrower;
Component on the chef that deals with throwing objects.private IClientHandleCatch m_iCatcher;
Component on the chef that deals with catching objects.private Vector3 m_lastVelocity;
Velocity on the last “chef movement” frame. Being used inUpdate_Movement
to compute friction:Vector3 vector3 = this.ProgressVelocityWrtFriction(this.m_lastVelocity, this.m_controls.Motion.GetVelocity(), vector2, this.GetSurfaceData()); this.m_controls.Motion.SetVelocity(vector3); this.m_lastVelocity = vector3;
private float m_dashTimer = float.MinValue;
- Remaining time of a dash action; CAN BE NEGATIVE. When pressing dash, the dash timer is set to PlayerControls.MovementData.DashTime. Then on every “chef movement” frame this is decremented by 1/60 of a second, and continues to go below zero.
- This is also used to compute the dash cooldown (this is why it needs to go below zero).
- It is also used to compute dash collision impact.
private float m_attemptedDistanceCounter;
Does not seem to be actually useful. Only read and written here:this.m_attemptedDistanceCounter += vector2.magnitude * _deltaTime; if (this.m_attemptedDistanceCounter >= movement.FootstepLength) { this.m_attemptedDistanceCounter %= movement.FootstepLength; }
private bool m_aimingThrow;
- If true, the chef is aiming to throw an item.
- This is used to show/hide the throw indicator.
- It is also used to suppress movement, see
m_movementInputSuppressed
.
private bool m_movementInputSuppressed;
If true, the chef does not move with the controls (the control’s movement axes are effectively zero); however it will still move with the dash velocity.private Vector3 m_lastMoveInputDirection;
- This stores the last movement input direction while the chef is aiming (if input magnitude is >=
m_controls.m_analogEnableDeadzoneThreshold
, which is 0.25). - After exiting from aiming mode or a session interaction, the movement suppression is only lifted when either
- the movement input magnitude is less than
m_controls.m_analogEnableDeadzoneThreshold
- the movement input direction is more than
m_controls.m_analogEnableAngleThreshold
(which is 15 degrees) away from them_lastMoveInputDirection
.
- the movement input magnitude is less than
- This stores the last movement input direction while the chef is aiming (if input magnitude is >=
private float m_impactStartTime = float.MaxValue;
The description is misleading; this is the total duration of the impact. It is set in ApplyImpact and not changed after.private float m_impactTimer = float.MinValue;
This is the remaining time of the impact. It is set to the same value asm_impactStartTime
at the beginning of ApplyImpact, and then it decrements by 1/60 of a second every “movement frame”.private Vector3 m_impactVelocity = Vector3.zero;
The initial impact velocity. This is only set in ApplyImpact and then used to calculate the chef velocity (along with movement and dash) in Update_Movement. The calculation formula is very similar to dash.private float m_timeOffGround;
- Increments when the chef is not in collision with ground; resets to zero otherwise.
- If this value reaches m_controls.m_timeBeforeFalling, ``m_isFalling` is set to true.
- The only thing that
m_isFalling
controls is a trigger that starts the falling animation.
private bool m_isFalling;
Seem_timeOffGround
.private VoidGeneric<ClientInteractable> m_interactTriggerCallback;
Used for animations.private VoidGeneric<GameObject> m_throwTriggerCallback;
Used for animations.private VoidGeneric<bool> m_fallingTriggerCallback;
Used for animations.private PlayerControlsHelper.ControlAxisData m_controlAxisData;
- This is a frame-temporary axis data built by
Update_Movement
and then used inUpdate_Rotation
andUpdateMovementSuppression
.
- This is a frame-temporary axis data built by
private PlayerIDProvider m_playerIDProvider;
This should point to the same object asm_controlsPlayer
.private float m_LeftOverTime;
Remaining time to calculate “movement frame”. This is incremented by Time.deltaTime every game frame, and then a movement frame happens as 1/60 second is subtracted fromm_LeftOverTime
.private OrderedMessageReceivedCallback m_onChefEffectReceived;
A callback used to handle received ChefEffect messages, i.e. multiplayer impact and dash.private uint m_entityID;
The entity ID of the chef.private float m_lastPickupTimestamp;
- Timestamp of the last pickup action. This time is from
ClientTime.Time()
, which is some complicated synchronized time? - This is used to prevent another pickup within 0.5 seconds (
m_controls.m_pickupDelay
).
- Timestamp of the last pickup action. This time is from
private LayerMask m_SlopedGroundMask = 0;
Something to do with gravity.
Fields of player control classes that require persisting for TAS warping
ClientInteractable m_predictedInteracted
- persisted as an entity reference. Needed to calculate next frame interaction decisions.- NOT
m_sessionInteraction
- we already record movement suppression, and in general it’s not a big deal because the controller won’t be trying to move the chef when it’s trying to interact with something, and we’ll also make sure that any sort of interactable will actually disable the control of the chef. Movement suppression is generally just not that reliable anyway because dashing is not prevented; it’s only good for aiming, really. - NOT
m_collisionRecorder
- colliding with another chef is unlikely to be helpful for TAS, we’ll just avoid it in general so it’s OK if this is not warped perfectly. Vector3 m_lastVelocity
; unlike rigidbody’s velocity, this is NOT impacted by pausing, because Update_Impl doesn’t call any of the movement update functions if the game is paused.float m_dashTimer
bool m_aimingThrow
bool m_movementInputSuppressed
Vector3 m_lastMoveInputDirection
float m_impactStartTime
float m_impactTimer
Vector3 m_impactVelocity
float m_LeftOverTime
- NOT
float m_lastPickupTimestamp
: We will limit this on the controller side. Otherwise this would require modding the field itself since it doesn’t hold a pausable timestamp right now.
3.6 - Cooking
Cooking Handler
ServerCookingHandler
manages the progress and state of cooking.
public void SetCookingProgress(float _cookingProgress)
{
CookingHandler cookingHandler = this.GetCookingHandler();
this.m_ServerData.m_cookingProgress = _cookingProgress;
CookingUIController.State cookingState = this.m_ServerData.m_cookingState;
if (this.IsBurning())
{
this.m_ServerData.m_cookingState = CookingUIController.State.Ruined;
}
else if (_cookingProgress >= cookingHandler.m_cookingtime)
{
if (this.m_ServerData.m_cookingState == CookingUIController.State.Progressing)
{
this.m_ServerData.m_cookingState = CookingUIController.State.Completed;
}
if (_cookingProgress > 1.3f * cookingHandler.m_cookingtime)
{
this.m_ServerData.m_cookingState = CookingUIController.State.OverDoing;
}
}
else
{
this.m_ServerData.m_cookingState = CookingUIController.State.Progressing;
}
if (this.m_ServerData.m_cookingState != cookingState)
{
this.m_cookingStateChangedCallback(this.m_ServerData.m_cookingState);
}
}
The cooking state is completely determined by the progress.
The m_cookingStateChangedCallback
is mostly used for cosmetics. It changes the order composition
(because it changes uncooked ingredients to cooked ones), and so can affect certain cosmetic
functions. There’s one exception to the cosmetics which is ServerCookingStation.OnItemAdded
, which
updates whether the cooking station should be cooking. Either way, calling this more than once
shouldn’t hurt.
Also, ServerCookingHandler
’s synchronization is one of the very few components that synchronize
automatically. It does so by using the EntitySynchronisationMessage
instead of EntityEventMessage
.
The message is automatically sent by ServerSynchronisationScheduler
by querying the component’s
GetServerUpdate()
.
ServerMixingHandler
Same with ServerCookingHandler
.
ServerCookableContainer
This is mostly a helper component. It does not have its own synchronized entity message.
ServerIngredientContainer
This also uses GetServerUpdate()
and is synchronized automatically. So correctness isn’t really an
issue. We just need to make sure to call OnContentsChanged()
upon warping.
ServerCookingStation
This controls two parameters: m_isCooking
and m_isTurnedOn
.
3.7 - Throwing
Throwing Basics
Throwing is pretty non-trivial in this game.
AttachmentThrower
is a component that must be present for anything that can throw an object. There are two I’m aware of: chef and teleportal. TheServerAttachmentThrower::ThrowItem
is the one performing the throwing action, but throwing is triggered via a few steps, starting elsewhere:- For chef, the
ClientPlayerControlsImpl_Default
listens for controller events inUpdate_Throw()
and then requests a throw by sending anChefEventType.Throw
event to the server. See Chef Throwing later - For teleportal, the
ServerTeleportalAttachmentReceiver
has a coroutine that initiates the throw when receiving a teleported throwable object.
- For chef, the
ServerThrowableItem
is a component that is present on anything that can be thrown. Its two important methods areCanHandleThrow
andHandleThrow
:CanHandleThrow
makes an object conditionally throwable. (Anything that is never throwable just doesn’t have theThrowableItem
component attached to it at all.) It has only one implementation, byServerPreparationContainer
(used by burger buns, tortillas, etc.) which is only throwable if it doesn’t have anything inside it.HandleThrow
does a few things. See later in Throwing Details.
Chef Throwing
After ClientPlayerControlsImpl_Default.Update_Throw
decides to send a throw event, the server counterpart, ServerPlayerControlsImpl_Default.ReceiveThrowEvent(GameObject _target)
processes the throwing.
public void ReceiveThrowEvent(GameObject _target)
{
if (_target != null)
{
if (this.m_iCarrier.InspectCarriedItem() == null || PlayerControlsHelper.IsHeldItemInsideStaticCollision(this.m_controls))
{
return;
}
IThrowable throwable = _target.RequireInterface<IThrowable>();
Vector3 v = this.m_controlObject.transform.forward.normalized.XZ();
if (throwable.CanHandleThrow(this.m_iThrower, v))
{
this.m_iCarrier.TakeItem();
throwable.HandleThrow(this.m_iThrower, v);
EntitySerialisationEntry entry = EntitySerialisationRegistry.GetEntry(_target);
this.SendServerEvent(new InputEventMessage(InputEventMessage.InputEventType.EndThrow)
{
entityId = entry.m_Header.m_uEntityID
});
}
else
{
this.m_iThrower.OnFailedToThrowItem(this.m_iCarrier.InspectCarriedItem());
}
}
}
First, this.m_iCarrier
is the ICarrier
component on the same chef; .InspectCarriedItem()
returns the item
currently being carried by the chef. It’s it’s null then we bail.
Then, we check if the item is inside static collision:
public static bool IsHeldItemInsideStaticCollision(PlayerControls _control)
{
if (PlayerControlsHelper.s_staticCollisionLayerMask == 0)
{
PlayerControlsHelper.s_staticCollisionLayerMask = LayerMask.GetMask(new string[]
{
"Default",
"Ground",
"Walls",
"Worktops",
"PlateStationBlock",
"CookingStationBlock"
});
}
ICarrier carrier = _control.gameObject.RequireInterface<ICarrier>();
IAttachment attachment = carrier.InspectCarriedItem().RequestInterface<IAttachment>();
Collider collider = attachment.AccessGameObject().RequestComponent<Collider>();
Bounds bounds = collider.bounds;
Quaternion rotation = collider.transform.rotation;
Vector3 position = collider.transform.position;
Vector3 halfExtents = bounds.extents * 0.5f;
int num = Physics.OverlapBoxNonAlloc(bounds.center, halfExtents, PlayerControlsHelper.s_collisions, rotation, PlayerControlsHelper.s_staticCollisionLayerMask, QueryTriggerInteraction.Ignore);
return num > 0;
}
This corresponds to the behavior that if, say, a cooking station is right in the chef’s face, the chef cannot throw anything. Similarly one cannot throw something into the wall.
After that, we check for CanHandleThrow
, which we discussed above. If this fails, we call OnFailedToThrowItem
, whose only implementation by ServerAttachmentThrower
does nothing, so we can ignore this.
Next, we TakeItem()
from the chef, which detaches the item from the chef.
Then we call ServerThrowableItem.HandleThrow
. Before we dig into it, we look at the final thing that happens after: sending a EndThrow message to the clients. This does nothing important except triggering a sound effect on the clients as well as some chef animations.
Throwing Details
Here’s the function that performs the throwing: ServerThrowableItem.HandleThrow
:
public void HandleThrow(IThrower _thrower, Vector2 _directionXZ)
{
_thrower.ThrowItem(base.gameObject, _directionXZ);
this.m_thrower = _thrower;
this.m_previousThrower = _thrower;
if (this.m_attachment == null)
{
this.m_attachment = base.gameObject.RequestInterface<IAttachment>();
}
if (this.m_attachment != null)
{
this.m_attachment.RegisterAttachChangedCallback(this.m_OnAttachChanged);
}
if (this.m_pickupReferral != null)
{
this.m_pickupReferral.SetHandlePickupReferree(this);
}
this.m_flying = true;
this.SendStateMessage(this.m_flying, ((MonoBehaviour)_thrower).gameObject);
this.ResumeAndClearThrowStartCollisions();
this.m_ignoredCollidersCount = Physics.OverlapBoxNonAlloc(this.m_Transform.position, this.m_Collider.bounds.extents, this.m_ThrowStartColliders, this.m_Transform.rotation, ServerThrowableItem.ms_AttachmentsLayer);
for (int i = 0; i < this.m_ignoredCollidersCount; i++)
{
Physics.IgnoreCollision(this.m_Collider, this.m_ThrowStartColliders[i], true);
}
}
The first call, ThrowItem
, is implemented by ServerAttachmentThrower
:
public void ThrowItem(GameObject _object, Vector2 _directionXZ)
{
IAttachment attachment = _object.RequestInterface<IAttachment>();
if (attachment != null)
{
Vector3 velocity = this.CalculateThrowVelocity(_directionXZ);
attachment.AccessMotion().SetVelocity(velocity);
ICatchable catchable = _object.RequestInterface<ICatchable>();
if (catchable != null)
{
this.AlertPotentialCatchers(catchable, attachment.AccessGameObject().transform.position, _directionXZ);
}
}
this.m_throwCallback(_object);
}
It sets a velocity on the attachment (which basically forwards the velocity to the Rigidbody container),
and then gets the ICatchable
side of the throwable and calls AlertPotentialCatchers
. The final m_throwCallback is always a no-op (nobody registers any logic on it).
private void AlertPotentialCatchers(ICatchable _object, Vector3 _position, Vector2 _directionXZ)
{
Vector3 direction = VectorUtils.FromXZ(_directionXZ, 0f);
int num = Physics.SphereCastNonAlloc(_position, 1f, direction, ServerAttachmentThrower.ms_raycastHits, 10f, ServerAttachmentThrower.m_playersLayerMask);
for (int i = 0; i < num; i++)
{
RaycastHit raycastHit = ServerAttachmentThrower.ms_raycastHits[i];
GameObject gameObject = raycastHit.collider.gameObject;
if (!(gameObject == base.gameObject))
{
IHandleCatch handleCatch = gameObject.RequestInterface<IHandleCatch>();
if (handleCatch != null)
{
handleCatch.AlertToThrownItem(_object, this, _directionXZ);
}
}
}
}
This queries the physics system for anything that intersects a radius-1 ray in the thrown direction. For any such
objects that isn’t the thrower itself, and implements IHandleCatch
, it calls AlertToThrownItem
.
Of the five implementations of IHandleCatch
, only one has non-empty logic: ServerAttachmentCatcher.AlertToThrownItem
, i.e. another potentially catching chef:
public void AlertToThrownItem(ICatchable _thrown, IThrower _thrower, Vector2 _directionXZ)
{
if (this.m_controls != null && this.m_controls.GetDirectlyUnderPlayerControl())
{
return;
}
if (this.m_trackedThrowable != null)
{
IThrowable throwable = this.m_trackedThrowable.RequireInterface<IThrowable>();
if (throwable.IsFlying())
{
return;
}
}
if (this.m_carrier.InspectCarriedItem() == null && this.m_controls.GetCurrentlyInteracting() == null)
{
this.m_trackedThrowable = _thrown.AccessGameObject();
this.SendTrackingData(this.m_trackedThrowable);
}
}
If the potential catching chef is under a player’s control (e.g. not in a cannon, not piloting a raft, not being the non-active chef of single player), then there’s nothing to do.
If the previously tracked throwable is still flying, there’s also nothing to do.
Otherwise, if the chef is not currently carrying something, and it’s not currently doing something (e.g. chopping), start tracking this throwable and sends the same to the client side that also sets its m_trackedThrowable
. This is then used in Update_Rotation
to automatically face the thrown object. In other words, AlertPotentialCatchers
is only for the purpose of non-active player rotation in single player.
Let’s come back to HandleThrow
. The next thing that happens is setting m_thrower
and m_previousThrower
:
m_thrower
is used for three purposes:- It is used to prevent in-flight collision with the thrower itself, in
ServerThrowableItem.OnCollisionEnter
. - It is used in
ServerAttachmentThrower.CanHandleCatch
to prevent catching an object thrown by the same chef. - It is used in
ServerHeatedStation.HandleCatch
andServerIngredientCatcher.HandleCatch
for achievement tracking.
- It is used to prevent in-flight collision with the thrower itself, in
m_previousThrower
is used forServerRubbishBin
for achievement tracking only.
Next, it lazily fetches IAttachment
for the item itself (only implemented by ServerPhysicalAttachment
),
and then registers a callback to call OnAttachChanged
which basically forcefully ends the flight if
someone else claims attachment.
Next, it sets the pickup referree of the item to itself, whose CanHandlePickup
function returns whether
the object is flying. (I doubt this would become important without some bad network latency, such as
someone picking up an object and before the message arrives, someone else already threw the object).
Next it sets m_flying
. This field is used in a few places:
- It prevents pickup in its
CanHandlePickup
implementation. OnCollisionEnter
does nothing if the object is not flying.- It is used to update flight time to detect landing.
- It is used by
ServerAttachmentCatcher
’s throwable tracking to detect if the object is still flying. - It is used by
ServerAttachStation.CanHandleCatch
to only catch an object if it’s NOT flying (TODO: this is cryptic). - It is used by
ServerCatchableItem.AllowCatch
to return false if the object is not flying or has only been flying for a short time. - It is added to the object’s
ServerLimitedQuantityItem
as an invincibility condition if the object is flying.
Next, it sends a message that the object is flying. On the client side (ClientThrowableItem
), this
triggers StartFlight
:
private void StartFlight(IClientThrower _thrower)
{
this.m_isFlying = true;
this.m_thrower = _thrower;
if (this.m_pickupReferral != null)
{
this.m_pickupReferral.SetHandlePickupReferree(this);
}
if (this.m_throwableItem.m_throwParticle != null)
{
Transform parent = NetworkUtils.FindVisualRoot(base.gameObject);
GameObject gameObject = this.m_throwableItem.m_throwParticle.InstantiateOnParent(parent, true);
this.m_pfx = gameObject.GetComponent<ParticleSystem>();
}
}
m_isFlying
here is used for the similar handle pickup referree’s CanHandlePickup
, as well as
ClientPlayerControlsImpl_Default.OnThrowableCollision
, which applies knockback if an object is thrown
onto a chef. Let’s not go into that mess here.
After that is a similar handle pickup referree, and particle effects.
Going back to server’s HandleThrow
. Next is ResumeAndClearThrowStartCollisions()
:
private void ResumeAndClearThrowStartCollisions()
{
for (int i = 0; i < this.m_ignoredCollidersCount; i++)
{
Collider collider = this.m_ThrowStartColliders[i];
if (collider != null && collider.gameObject != null)
{
Physics.IgnoreCollision(this.m_Collider, collider, false);
this.m_ThrowStartColliders[i] = null;
}
}
this.m_ignoredCollidersCount = 0;
}
this is to clear the previously ignored collisions, if any somehow remains (because the same thing
is done in EndFlight
).
Finally, we check for any initially colliding items (within the “Attachments” layer) with the thrown item, and ignore collisions for all of them. This allows us to throw an object over another object (such as if there’s a strawberry on the counter right in front of us we can still throw another strawberry over it).
During Flight
ServerThrowableItem.UpdateSynchronising
does this every frame:
public override void UpdateSynchronising()
{
float deltaTime = TimeManager.GetDeltaTime(base.gameObject);
if (this.IsFlying())
{
this.m_flightTimer += deltaTime;
if (this.m_flightTimer >= 0.1f)
{
IAttachment component = base.GetComponent<IAttachment>();
if (component.AccessMotion().GetVelocity().sqrMagnitude < 0.0225f)
{
this.EndFlight();
}
if (this.m_flightTimer >= 5f)
{
this.EndFlight();
}
this.ResumeAndClearThrowStartCollisions();
}
}
else
{
this.m_throwerTimer -= deltaTime;
if (this.m_throwerTimer <= 0f)
{
this.m_previousThrower = null;
}
}
}
This essentially says that:
- End the flight if we have been flying for more than 5 seconds, or for at least 0.1 second and the velocity is below 0.15.
- If we’ve been flying for at least 0.1 second, regardless of these conditions, call
ResumeAndClearThrowStartCollisions()
, i.e. stop ignoring the initial collisions.- According to the game data, the initial throwing velocity is 18. In 0.1 second the object should have travelled for 1.8, which is longer than the diagonal of a grid cell (1.2 * sqrt(2)). So definitely we would have cleared any obstacles in this time.
- If not flying, then the
m_previousThrower
field times out after a timeout. This is unimportant; we’ve seen earlier that this field is only used for tracking rubbish bin achievement.
In addition, there’s the ServerThrowableItem.OnCollisionEnter
, which is a Unity lifecycle event when
the object collides with something:
private void OnCollisionEnter(Collision _collision)
{
if (!this.IsFlying())
{
return;
}
IThrower thrower = _collision.gameObject.RequestInterface<IThrower>();
if (thrower != null && thrower == this.m_thrower)
{
return;
}
Vector3 normal = _collision.contacts[0].normal;
float num = Vector3.Angle(normal, Vector3.up);
if (num <= 45f)
{
this.EndFlight();
this.m_landedCallback(_collision.gameObject);
}
}
- If it’s not flying anymore, do nothing.
- If the collision is with the original thrower, do nothing.
- If the collision contact normal is less than 45 degrees with the up direction, end flight and trigger landing
(i.e. the thing is more similar to a ground, not a wall).
- the m_landedCallback is always no-op. The only registrant of this callback is
ServerSplattable
(which is some logic that destroys the throwable if someone throws it in a hazard, like fire); however,ServerSplattable
is not attached toSplattable
objects (it’s missing inMultiplayerController
), so it’s never instantiated.
- the m_landedCallback is always no-op. The only registrant of this callback is
Landing or Ending Flight
Flight is ended by any of the following:
- Colliding with an eligible object at an eligible angle in
ServerThrowableItem.OnCollisionEnter
. - Reaching a low speed or exceeding flight time in
ServerThrowableItem.UpdateSynchronising
. - Being attached to a parent, in
ServerThrowableItem.OnAttachChanged
.
EndFlight()
is called in any of these cases, which effectively undoes all the steps of HandleThrow()
,
and additionally the m_previousThrower
and m_throwerTimer
is set. The former is used for achievement
tracking only, and the latter is just a timeout to set the former to null.
public void EndFlight()
{
if (this.m_pickupReferral != null && this.m_pickupReferral.GetHandlePickupReferree() == this)
{
this.m_pickupReferral.SetHandlePickupReferree(null);
}
if (this.m_attachment != null)
{
this.m_attachment.UnregisterAttachChangedCallback(this.m_OnAttachChanged);
}
this.m_previousThrower = this.m_thrower;
this.m_throwerTimer = this.m_throwableItem.m_throwerTimeout;
this.m_thrower = null;
this.m_flightTimer = 0f;
this.m_flying = false;
this.SendStateMessage(this.m_flying, null);
this.ResumeAndClearThrowStartCollisions();
}
Catching
Catching has three aspects: the catchable item, the catcher, and the catching processor.
Catchable item
On the catchable item side, there’s a single interface ICatchable
whose only implementation is ServerCatchableItem
.
(The counterpart, ClientCatchableItem
, is an empty class.)
It is a pretty simple class that contains only one important function (comments inlined):
public bool AllowCatch(IHandleCatch _catcher, Vector2 _directionXZ)
{
if (this.m_throwable == null)
{
// This is just defensive programming in case the object does not have
// a IThrowable component.
return false;
}
GameObject gameObject = (_catcher as MonoBehaviour).gameObject;
if (_catcher != null && gameObject == null)
{
// This looks like a very weird condition but Unity Objects have an
// internal reference that may be null, and the operator == is
// overloaded to compare equal to null if the internal reference is null.
// So it's possible for the _catcher (who is an ICatchable, not a
// UnityEngine.Object) to not be null but its gameObject's operator==
// to return null. This means the object is already destroyed.
//
// Still, this is bad programming because _catcher can't be null anyway
// or it would trigger an null reference exception above.
return false;
}
if (!(gameObject.RequestComponent<ServerAttachStation>() != null) && !(gameObject.RequestComponent<ServerIngredientContainer>() != null))
{
if (!this.m_throwable.IsFlying() || (this.m_throwable.IsFlying() && this.m_throwable.GetFlightTime() < 0.1f))
{
return false;
}
}
// The item is catchable (from the item's perspective) if:
// - The catcher is a ServerAttachStation; or
// - The catcher is a ServerIngredientContainer; or
// - The item has been flying for more than 0.1f and it's still flying.
return true;
}
Catcher
On the catcher side the interface is IHandleCatch
. This generally queries AllowCatch
on the
catchable item first, so the catcher can be the sole decider.
There are five implementations of IHandleCatch
:
ServerAttachmentCatcher
: chefs. Note that teleportals don’t seem to use this component.ServerAttachStation
: countertops and anything that act like countertops, like chopping boards.ServerHeatedStation
: these are the heating stations in campfire levels where chopped firewood can be thrown in. The component is also used in Surf n’ Turf barbeque stations and even coal furnaces but those don’t catch anything.ServerIngredientCatcher
: containers that can catch ingredients, e.g. pans, mixers, pots.ServerPlayerAttachmentCarrier.BlockCatching
: this is a special implementation used to block catching when an object is being held by a chef.
Let’s go through these one by one:
ServerAttachmentCatcher
:
public bool CanHandleCatch(ICatchable _object, Vector2 _directionXZ)
{
if (!_object.AllowCatch(this, _directionXZ))
{
return false;
}
GameObject gameObject = _object.AccessGameObject();
if (this.m_carrier.InspectCarriedItem() != null)
{
// If chef is already holding something, don't catch.
return false;
}
IThrower thrower = base.gameObject.RequestInterface<IThrower>();
if (thrower != null)
{
IThrowable throwable = gameObject.RequireInterface<IThrowable>();
if (throwable.GetThrower() == thrower)
{
// The chef throwing the object can't catch it (e.g. smashing
// against a wall.)
return false;
}
}
// In game data, this is 1.8. Catching will not happen if the item is
// further away from the chef than 1.8 (1.5 * grid size).
float catchDistance = this.m_catcher.m_catchDistance;
if ((base.transform.position - gameObject.transform.position).sqrMagnitude > catchDistance * catchDistance)
{
return false;
}
// Finally, catching can only happen if the chef is facing less than 120 degrees
// from the direction the item is flying (horizontal directions only).
//
// Note that catching at 120 degrees may seem weird but can happen if someone throws
// from the side but slightly behind you, towards a spot in front of you. If the
// thrown object would hit you first, it would knock you to the side instead of
// triggering a catch.
IAttachment attachment = gameObject.RequireInterface<IAttachment>();
Vector2 from = attachment.AccessMotion().GetVelocity().XZ();
Vector2 a = base.transform.forward.XZ();
float num = Vector2.Angle(from, -a);
return num <= this.m_catcher.m_catchAngleMax;
}
public void HandleCatch(ICatchable _object, Vector2 _directionXZ)
{
GameObject @object = _object.AccessGameObject();
// Cause the chef to carry the item.
this.m_carrier.CarryItem(@object);
// Stop tracking the item (for chef rotation; see the throwing section).
this.m_trackedThrowable = null;
this.SendTrackingData(this.m_trackedThrowable);
}
ServerAttachStation
:
public bool CanHandleCatch(ICatchable _object, Vector2 _directionXZ)
{
if (!_object.AllowCatch(this, _directionXZ))
{
return false;
}
if (this.HasItem())
{
// If there's something on top of the AttachStation that can also catch, forward the call to that.
IHandleCatch handleCatch = this.m_item.AccessGameObject().RequestInterface<IHandleCatch>();
if (handleCatch != null)
{
return handleCatch.CanHandleCatch(_object, _directionXZ);
}
}
IThrowable throwable = _object.AccessGameObject().RequestInterface<IThrowable>();
// Conditions for catching:
// - Object is throwable (should always be true for catchable objects).
// - The object is NOT flying.
// - The station can catch things (this is a setting that the game devs can toggle).
// - The object would normally be attachable to the station (this is a complicated query, but
// can only be true if the station is empty to begin with. It handles things like, a frier
// cannot be attached to a normal heating station).
return throwable != null && !throwable.IsFlying() && this.m_station.m_canCatch && this.CanAttachToSelf(_object.AccessGameObject(), new PlacementContext(PlacementContext.Source.Game));
}
public void HandleCatch(ICatchable _object, Vector2 _directionXZ)
{
if (this.HasItem())
{
// Forward catching to the catcher on the station if there is one.
IHandleCatch handleCatch = this.m_item.AccessGameObject().RequestInterface<IHandleCatch>();
if (handleCatch != null)
{
handleCatch.HandleCatch(_object, _directionXZ);
}
}
else
{
// Otherwise just simply attach the object.
this.AttachObject(_object.AccessGameObject(), default(PlacementContext));
}
}
ServerHeatedStation
:
public bool CanHandleCatch(ICatchable _object, Vector2 _directionXZ)
{
if (!_object.AllowCatch(this, _directionXZ))
{
return false;
}
// Conditions for catching:
// - If this is a heat transfer item (i.e. firewood); OR
// - If this is normally catchable by the associated attach station.
IHeatTransferBehaviour heatTransferBehaviour = _object.AccessGameObject().RequestInterface<IHeatTransferBehaviour>();
return (heatTransferBehaviour != null && heatTransferBehaviour.CanTransferToContainer(this)) || (this.m_attachStation != null && this.m_attachStation.CanHandleCatch(_object, _directionXZ));
}
// This one is pretty straight forward.
public void HandleCatch(ICatchable _object, Vector2 _directionXZ)
{
GameObject gameObject = _object.AccessGameObject();
IHeatTransferBehaviour heatTransferBehaviour = gameObject.RequestInterface<IHeatTransferBehaviour>();
if (heatTransferBehaviour != null)
{
IThrowable throwable = gameObject.RequireInterface<IThrowable>();
IThrower thrower = throwable.GetThrower();
if (thrower != null)
{
this.BurnAchievement((thrower as MonoBehaviour).gameObject, gameObject);
}
ICarrier carrier = new ServerHeatedStation.HolderAdapter(gameObject);
heatTransferBehaviour.TransferToContainer(carrier, this);
this.SynchroniseItemAdded();
return;
}
if (this.m_attachStation != null && this.m_attachStation.CanHandleCatch(_object, _directionXZ))
{
this.m_attachStation.HandleCatch(_object, _directionXZ);
}
}
ServerIngredientCatcher
:
public bool CanHandleCatch(ICatchable _object, Vector2 _directionXZ)
{
if (!_object.AllowCatch(this, _directionXZ))
{
return false;
}
// If m_requireAttached is configured for this object, only catch if
// the container is on an attach station. (For example, mixer can't catch
// food if it's on the floor).
if (this.m_catcher.m_requireAttached)
{
if (this.m_attachment == null)
{
return false;
}
if (!this.m_attachment.IsAttached())
{
return false;
}
}
// This is a complicated query that determines whether the container can
// accept an ingredient of this type. For example you cannot throw onion into
// a pan.
if (!this.m_allowCatchingCallback(_object.AccessGameObject()))
{
return false;
}
// A further complicated query to make sure the item can be transferred to the container.
IOrderDefinition orderDefinition = _object.AccessGameObject().RequestInterface<IOrderDefinition>();
if (orderDefinition != null && orderDefinition.GetOrderComposition().Simpilfy() != AssembledDefinitionNode.NullNode)
{
IContainerTransferBehaviour containerTransferBehaviour = _object.AccessGameObject().RequestInterface<IContainerTransferBehaviour>();
ServerIngredientContainer component = base.gameObject.GetComponent<ServerIngredientContainer>();
if (containerTransferBehaviour != null && component != null && containerTransferBehaviour.CanTransferToContainer(component))
{
return component.CanAddIngredient(orderDefinition.GetOrderComposition());
}
}
return false;
}
public void HandleCatch(ICatchable _object, Vector2 _directionXZ)
{
GameObject gameObject = _object.AccessGameObject();
IThrowable throwable = gameObject.RequireInterface<IThrowable>();
IThrower thrower = throwable.GetThrower();
if (thrower != null)
{
GameObject gameObject2 = (thrower as MonoBehaviour).gameObject;
ServerMessenger.Achievement(gameObject2, 12, 1);
}
IOrderDefinition orderDefinition = gameObject.RequireInterface<IOrderDefinition>();
if (orderDefinition.GetOrderComposition().Simpilfy() != AssembledDefinitionNode.NullNode)
{
IContainerTransferBehaviour containerTransferBehaviour = gameObject.RequireInterface<IContainerTransferBehaviour>();
ServerIngredientContainer component = base.gameObject.GetComponent<ServerIngredientContainer>();
if (containerTransferBehaviour != null && component != null && containerTransferBehaviour.CanTransferToContainer(component))
{
// Have the item decide how to transfer to the container.
containerTransferBehaviour.TransferToContainer(null, component, true);
// Send clients a message about the change of contents.
component.InformOfInternalChange();
// Destroy the thrown item.
gameObject.SetActive(false);
NetworkUtils.DestroyObject(gameObject);
}
}
}
Finally, ServerPlayerAttachmentCarrier.BlockCatching.CanHandleCatch
just returns false.
Catching processor
Catchable items and catchers both do not actually trigger any catching. They only decide whether something can be caught and what to do when catching. There appears to be two ways that the catching action is actually triggered:
ServerAttachmentCatchingProxy
. It is automatically added to objects with aHeatedStation
,AttachStation
, orIngredientCatcher
component (seeMultiplayerController.AsyncScanEntities
).- On every frame, this component will check all its collisions that are IAttachment, and calls AttemptToCatch.
private void AttemptToCatch(GameObject _object) { ICatchable catchable = _object.RequestInterface<ICatchable>(); if (catchable != null) { IHandleCatch controllingCatchingHandler = this.GetControllingCatchingHandler(); if (controllingCatchingHandler != null) { Vector2 directionXZ = (_object.transform.position - base.transform.position).SafeNormalised(base.transform.forward).XZ(); if (controllingCatchingHandler.CanHandleCatch(catchable, directionXZ)) { controllingCatchingHandler.HandleCatch(catchable, directionXZ); } } } else { Vector2 param = (_object.transform.position - base.transform.position).SafeNormalised(base.transform.forward).XZ(); if (this.m_uncatchableItemCallback != null) { // This is only registered by one party: ServerAttachStation.HandleUncatchableItem. It's used to // "catch" stuff like plates that happen to collide with the station. this.m_uncatchableItemCallback(_object, param); } } } public IHandleCatch GetControllingCatchingHandler() { // If there's a referree then return it. if (this.m_iHandleCatchReferree != null) { return this.m_iHandleCatchReferree; } // Otherwise return the IHandleCatch with the highest priority, if there are multiple // of these on the same GameObject. // I think this is only needed between ServerAttachStation and ServerHeatedStation, // where the latter returns a int.MaxValue. IHandleCatch handleCatch = null; for (int i = 0; i < this.m_iHandleCatches.Length; i++) { IHandleCatch handleCatch2 = this.m_iHandleCatches[i]; if ((!(handleCatch2 is MonoBehaviour) || (handleCatch2 as MonoBehaviour).enabled) && (handleCatch == null || handleCatch2.GetCatchingPriority() > handleCatch.GetCatchingPriority())) { handleCatch = handleCatch2; } } return handleCatch; }
- The
m_iHandleCatchReferree
is set in two cases:ServerAttachStation
sets the referree on the item to the station, if the item has aServerAttachmentCatchingProxy
. In other words, if we place a mixer bowl on a counter, and the mixer bowl detects a collision, we force the handling to be done by the station and not the mixer bowl.- When a chef is carrying a
ServerAttachmentCatchingProxy
, the referree is set toServerPlayerAttachmentCarrier.BlockCatching
, which prevents the carried item from catching anything. - Both of these cases should only be relevant when the attached item is a ingredient catcher. I can’t think of any other cases.
- On every frame, this component will check all its collisions that are IAttachment, and calls AttemptToCatch.
ServerPlayerControlsImpl_Default
implements the chef catching behavior:In other words, the chef scans for anything in front of it within radius 2 and initiates catching. Later we’ve seen above that there’s also a 1.8 minimum distance between the center of the chef and the center of the object, and a max 120 angle between chef forward and object velocity. The catching is done on the server side unlike most of the other chef controls.private void Update_Catch(float _deltaTime) { if (this.m_controls.m_bRespawning) { return; } // See below. ICatchable catchable = this.m_controls.ScanForCatch(); if (catchable != null) { MonoBehaviour monoBehaviour = (MonoBehaviour)catchable; if (monoBehaviour == null || monoBehaviour.gameObject == null) { return; } Vector2 normalized = this.m_controlObject.transform.forward.XZ().normalized; if (this.m_iCatcher.CanHandleCatch(catchable, normalized)) { this.m_iCatcher.HandleCatch(catchable, normalized); EntitySerialisationEntry entry = EntitySerialisationRegistry.GetEntry(monoBehaviour.gameObject); // This only triggers some audio and pfx on the client side. The actual attachment behavior // is already sent over in the CarryItem call inside HandleCatch. this.SendServerEvent(new InputEventMessage(InputEventMessage.InputEventType.Catch) { entityId = entry.m_Header.m_uEntityID }); } } } public ICatchable ScanForCatch() { // GetCollidersInArc is a complex function but when the last argument is false, it scans for all objects whose // closest point lies in an arc in front of the chef. It returns position + 0.5 * forward * detectionRadius, so // 0.5 in front of the chef. // The 2f here is the distance, and 1.57f here is pi/2 so anywhere in front of the chef. Vector3 collidersInArc = InteractWithItemHelper.GetCollidersInArc(2f, 1.5707964f, this.m_Transform, this.m_colliders, this.m_catchMask, false); // This then gets whatever ServerCatchableItem we can find from these colliders, prioritizing the closest one to // that chef + 0.5 point. return InteractWithItemHelper.ScanForComponent<ServerCatchableItem>(this.m_colliders, collidersInArc, this.m_CatchCondition); }
Warping
States we need to warp:
m_flying
(if changing, perform flight start/end actions)m_flightTimer
m_thrower
m_ThrowStartColliders
(if changing, perform physics collision changes)
Things to do when warping from non-flight to flight:
- Set pickup referral, server & client
- Register attach changed callback
3.8 - Plate Stacks
Relevant classes:
ServerStack
ServerPlateStackBase
ServerCleanPlateStack
ServerDirtyPlateStack
and their client counterparts
Stack
ServerStack
does not synchronize anything using messages; it only provides some functions
to add or remove elements from the stack:
// ServerStack::AddToStack
public void AddToStack(GameObject _item)
{
if (this.m_stackItems.Contains(_item))
{
return;
}
// IAttachment's only implementation is ServerPhysicalAttachment.
// Clean plates are PhysicalAttachment's. Dirty plates aren't.
// PhysicalAttachment objects have more things to do when attaching/detaching.
IAttachment attachment = _item.RequestInterface<IAttachment>();
if (attachment != null)
{
attachment.Attach(this.m_stack);
}
else
{
_item.transform.SetParent(this.m_stack.GetAttachPoint(_item));
}
this.m_stackItems.Add(_item);
// Properly positions and aligns each item in the stack.
this.m_stack.RefreshStackTransforms(ref this.m_stackItems);
// Properly calculates the height of the colliders for the stack.
this.m_stack.RefreshStackCollider(ref this.m_boxCollider, this.m_stackItems.Count);
}
// ServerStack::RemoveFromStack - does the reverse, and recalculates transforms and collider.
public GameObject RemoveFromStack()
{
if (this.m_stackItems.Count > 0)
{
GameObject gameObject = this.m_stackItems[this.m_stackItems.Count - 1];
this.m_stackItems.Remove(gameObject);
IAttachment attachment = gameObject.RequestInterface<IAttachment>();
if (attachment != null)
{
attachment.Detach();
}
else
{
gameObject.transform.SetParent(null);
}
this.m_stack.RefreshStackTransforms(ref this.m_stackItems);
this.m_stack.RefreshStackCollider(ref this.m_boxCollider, this.m_stackItems.Count);
return gameObject;
}
return null;
}
On the client side, the things to do are very similar:
// ServerStack::AddToStack
public void AddToStack(GameObject _item)
{
if (this.m_stackItems.Contains(_item))
{
return;
}
if (_item.RequestInterface<IClientAttachment>() == null)
{
_item.transform.SetParent(this.m_stack.GetAttachPoint(_item));
}
this.m_stackItems.Add(_item);
this.m_stack.RefreshStackTransforms(ref this.m_stackItems);
this.m_stack.RefreshStackCollider(ref this.m_boxCollider, this.m_stackItems.Count);
}
// ServerStack::RemoveFromStack
public GameObject RemoveFromStack()
{
if (this.m_stackItems.Count > 0)
{
GameObject gameObject = this.m_stackItems[this.m_stackItems.Count - 1];
this.m_stackItems.Remove(gameObject);
if (gameObject.RequestInterface<IClientAttachment>() == null)
{
gameObject.transform.SetParent(null);
}
this.m_stack.RefreshStackTransforms(ref this.m_stackItems);
this.m_stack.RefreshStackCollider(ref this.m_boxCollider, this.m_stackItems.Count);
return gameObject;
}
return null;
}
Note that AddToStack
is NOT called by some server message propagated from ServerStack
.
We’ll later cover what calls ServerStack
/ClientStack
.
The functions on the client side fills in the non-synchronizing case where the object is
not a PhysicalAttachment
(which would automatically synchronize the attachment on the
client side), as well as ensuring that the transforms/collider are refreshed.
PlateStackBase
Now we turn to the other component: ServerPlateStackBase
, which is a separate component not
directly related to Stack.
In the StartSynchronising
function we add a spawnable prefab for
the element in the stack:
// ServerPlateStackBase::StartSynchronising
public override void StartSynchronising(Component synchronisedObject)
{
base.StartSynchronising(synchronisedObject);
this.m_plateStack = (PlateStackBase)synchronisedObject;
this.m_stack = base.gameObject.RequireComponent<ServerStack>();
NetworkUtils.RegisterSpawnablePrefab(base.gameObject, this.m_plateStack.m_platePrefab);
}
Then the add/remove functions (AddToStack is triggered externally):
// ServerPlateStackBase::AddToStack
public virtual void AddToStack()
{
// Adding to stack always involves spawning a prefab for the inner element.
GameObject item = NetworkUtils.ServerSpawnPrefab(base.gameObject, this.m_plateStack.m_platePrefab);
this.m_stack.AddToStack(item);
}
// ServerPlateStackBase::RemoveFromStack (this is virtual, overridden by ServerCleanPlateStack)
protected virtual GameObject RemoveFromStack()
{
GameObject result = this.m_stack.RemoveFromStack();
// This is an empty event message; the message is only used for removal from the stack.
this.SendServerEvent(this.m_data);
return result;
}
On the client side, adding to a plate is triggered by a spawning callback:
// ClientPlateStackBase::StartSynchronising
public override void StartSynchronising(Component synchronisedObject)
{
base.StartSynchronising(synchronisedObject);
this.m_plateStack = (PlateStackBase)synchronisedObject;
this.m_stack = base.gameObject.RequireComponent<ClientStack>();
// This callback is called when a prefab is spawned; the same does NOT happen on the server side.
NetworkUtils.RegisterSpawnablePrefab(base.gameObject, this.m_plateStack.m_platePrefab, new VoidGeneric<GameObject>(this.PlateSpawned));
}
// ClientPlateStackBase::ApplyServerEvent
public override void ApplyServerEvent(Serialisable serialisable)
{
this.PlateRemoved();
}
// ClientPlateStackBase::PlateSpawned (overridden later)
protected virtual void PlateSpawned(GameObject _object)
{
this.m_plateAdded(_object); // cosmetics
}
// ClientPlateStackBase::PlateRemoved (overridden later)
protected virtual void PlateRemoved()
{
this.m_plateRemoved(null); // cosmetics
}
DirtyPlateStack
ServerDirtyPlateStack
registers two additional spawnable prefabs: a clean plate stack and a clean
single plate, because washing with the water jet can turn the stack directly into a clean stack or a
single plate.
Placing a dirty stack on top of another dirty stack triggers HandlePlacement
:
// ServerDirtyPlateStack::HandlePlacement
public void HandlePlacement(ICarrier _carrier, Vector2 _directionXZ, PlacementContext _context)
{
GameObject obj = _carrier.InspectCarriedItem();
ServerStack serverStack = obj.RequireComponent<ServerStack>();
for (int i = 0; i < serverStack.GetSize(); i++)
{
this.AddToStack();
}
_carrier.DestroyCarriedItem();
}
this just adds N new plates to the stack and destroys the original.
AddToStack
is overridden to update washable properties. There’s also OnWashingFinished
which
I’ll skip here because it’s complicated and mostly deals with different cases of respawning the
clean plate stack or single clean plate in the same place (chef’s hands or a counter).
// ServerDirtyPlateStack::AddToStack
public override void AddToStack()
{
base.AddToStack();
if (this.m_washable != null)
{
Washable washable = base.gameObject.RequireComponent<Washable>();
this.m_washable.SetDuration((float)(washable.m_duration * this.m_stack.GetSize()));
}
}
The other place where ServerDirtyPlateStack::AddToStack
is called is in ServerPlateReturnStation
,
when a dirty plate is supposed to be returned after a plate was delivered for 7 seconds. (Note that
the same logic is also used to respawn clean plate stacks.)
// ServerPlateReturnStation::ReturnPlate
public void ReturnPlate()
{
if (this.m_stack == null)
{
this.CreateStack();
}
this.m_stack.AddToStack();
}
There isn’t a lot of useful things happening on ClientDirtyPlateStack
.
CleanPlateStack
// ServerCleanPlateStack::AddToStack
public override void AddToStack()
{
bool flag = false;
if (this.m_stack.InspectTopOfStack() != null)
{
IIngredientContents ingredientContents = this.m_stack.InspectTopOfStack().RequestInterface<IIngredientContents>();
flag = (ingredientContents != null && ingredientContents.HasContents());
}
if (flag)
{
// If the top plate has stuff on it, remove it first, add a new plate, then add it back.
GameObject item = this.m_stack.RemoveFromStack();
this.AddToBaseStack();
this.m_stack.AddToStack(item);
}
else
{
// Otherwise just add one.
this.AddToBaseStack();
}
// For the plate that was just added, set the pickup and placement referral to the stack.
// (Isn't this wrong if the plate we added was the second item from the top??)
// (But then maybe it doesn't matter - when would the plate actually be queried first?)
GameObject obj = this.m_stack.InspectTopOfStack();
ServerHandlePickupReferral serverHandlePickupReferral = obj.RequestComponent<ServerHandlePickupReferral>();
if (serverHandlePickupReferral != null)
{
serverHandlePickupReferral.SetHandlePickupReferree(this);
}
ServerHandlePlacementReferral serverHandlePlacementReferral = obj.RequestComponent<ServerHandlePlacementReferral>();
if (serverHandlePlacementReferral != null)
{
serverHandlePlacementReferral.SetHandlePlacementReferree(this);
}
}
// ServerCleanPlateStack::AddToBaseStack
private void AddToBaseStack()
{
// The base class's function wich spawns the plate and adds it to the stack.
base.AddToStack();
GameObject obj = this.m_stack.InspectTopOfStack();
// This is for if the spawned plate would be destroyed, respawn it where the stack would respawn.
ServerUtensilRespawnBehaviour serverUtensilRespawnBehaviour = base.gameObject.RequestComponent<ServerUtensilRespawnBehaviour>();
ServerUtensilRespawnBehaviour serverUtensilRespawnBehaviour2 = obj.RequestComponent<ServerUtensilRespawnBehaviour>();
if (serverUtensilRespawnBehaviour != null && serverUtensilRespawnBehaviour2 != null)
{
serverUtensilRespawnBehaviour2.SetIdealRespawnLocation(serverUtensilRespawnBehaviour.GetIdealRespawnLocation());
}
}
// ServerCleanPlateStack::RemoveFromStack
protected override GameObject RemoveFromStack()
{
GameObject gameObject = base.RemoveFromStack();
// Reverses the referree settings.
ServerHandlePickupReferral serverHandlePickupReferral = gameObject.RequestComponent<ServerHandlePickupReferral>();
if (serverHandlePickupReferral != null && serverHandlePickupReferral.GetHandlePickupReferree() == this)
{
serverHandlePickupReferral.SetHandlePickupReferree(null);
}
ServerHandlePlacementReferral serverHandlePlacementReferral = gameObject.RequestComponent<ServerHandlePlacementReferral>();
if (serverHandlePlacementReferral != null && serverHandlePlacementReferral.GetHandlePlacementReferree() == this)
{
serverHandlePlacementReferral.SetHandlePlacementReferree(null);
}
return gameObject;
}
Now the client side… is a bit strange.
// ClientCleanPlateStack::PlateSpawned
protected override void PlateSpawned(GameObject _object)
{
bool flag = false;
if (this.m_stack.InspectTopOfStack() != null)
{
IIngredientContents ingredientContents = this.m_stack.InspectTopOfStack().RequestInterface<IIngredientContents>();
flag = (ingredientContents != null && ingredientContents.HasContents());
}
if (flag)
{
GameObject item = this.m_stack.RemoveFromStack();
this.m_stack.AddToStack(_object);
this.m_stack.AddToStack(item);
}
else
{
this.m_stack.AddToStack(_object);
}
this.m_delayedSetupPlates.Add(_object);
base.NotifyPlateAdded(_object);
}
// ClientCleanPlateStack::DelayedPlateSetup
private void DelayedPlateSetup(GameObject _plate)
{
if (_plate != null && _plate.gameObject != null)
{
ClientHandlePickupReferral clientHandlePickupReferral = _plate.RequestComponent<ClientHandlePickupReferral>();
if (clientHandlePickupReferral != null)
{
clientHandlePickupReferral.SetHandlePickupReferree(this);
}
ClientHandlePlacementReferral clientHandlePlacementReferral = _plate.RequestComponent<ClientHandlePlacementReferral>();
if (clientHandlePlacementReferral != null)
{
clientHandlePlacementReferral.SetHandlePlacementReferree(this);
}
}
}
// ClientCleanPlateStack::PlateRemoved
protected override void PlateRemoved()
{
GameObject gameObject = this.m_stack.RemoveFromStack();
ClientHandlePickupReferral clientHandlePickupReferral = gameObject.RequestComponent<ClientHandlePickupReferral>();
if (clientHandlePickupReferral != null && clientHandlePickupReferral.GetHandlePickupReferree() == this)
{
clientHandlePickupReferral.SetHandlePickupReferree(null);
}
ClientHandlePlacementReferral clientHandlePlacementReferral = gameObject.RequestComponent<ClientHandlePlacementReferral>();
if (clientHandlePlacementReferral != null && clientHandlePlacementReferral.GetHandlePlacementReferree() == this)
{
clientHandlePlacementReferral.SetHandlePlacementReferree(null);
}
base.NotifyPlateRemoved(gameObject);
}
// ClientCleanPlateStack::UpdateSynchronising
public override void UpdateSynchronising()
{
for (int i = this.m_delayedSetupPlates.Count - 1; i >= 0; i--)
{
this.DelayedPlateSetup(this.m_delayedSetupPlates[i]);
this.m_delayedSetupPlates.RemoveAt(i);
}
}
So… no surprises except that the setting of the referrees on the client side is delayed by 1 frame. Why??
3.9 - Cannon
Cannon structure
ClientCannon
- handles most animations, e.g. the coroutine for the main flightServerCannon
- ?IClientCannonHandler
- handler that can be used to launch different kinds of objects. Currently there’s only one implementation -ClientCannonPlayerHandler
.
Launch process
LaunchProjectile
is the main entry point to the coroutine. This is in response to
receiving a server event to launch the projectile.
private IEnumerator LaunchProjectile(GameObject _objectToLaunch, Transform _target)
{
IClientCannonHandler handler = this.GetHandler(_objectToLaunch);
if (handler != null)
{
handler.Launch(_objectToLaunch);
}
_objectToLaunch.transform.SetParent(null, true);
if (this.m_onLaunchedCallback != null)
{
this.m_onLaunchedCallback(_objectToLaunch);
}
IEnumerator animation = this.m_cannon.m_animation.Run(_objectToLaunch, _target);
while (animation.MoveNext())
{
yield return null;
}
if (handler != null)
{
handler.Land(_objectToLaunch);
}
this.m_cannon.EndCannonRoutine(_objectToLaunch);
if (handler != null)
{
IEnumerator exit = handler.ExitCannonRoutine(_objectToLaunch, _target.position, _target.rotation);
while (exit.MoveNext())
{
yield return null;
}
}
yield break;
}
Call
handler.Launch
. This sets up the player - disabling controls, setting kinematic, disabling collider. SeeClientCannonPlayerHandler.Launch
:public void Launch(GameObject _obj) { if (_obj != null) { this.m_inCannon = false; this.m_controls.enabled = false; this.m_controls.Motion.SetKinematic(true); Collider collider = _obj.RequireComponent<Collider>(); collider.enabled = false; } }
Detach the transform of the object being launched from the cannon.
Call the
m_onLaunchedCallback
if exists. This callback is only registered byClientCannonCosmeticDecisions
, so it’s cosmetics only (animations, particles, audio).Start the
ProjectileAnimation
animation coroutine. SeeProjectileAnimation::Run
; this manually animates the projectile by positioning it according to a parabola.Call the
handler.Land
function; seeClientCannonPlayerHandler.Land
:public void Land(GameObject _obj) { if (_obj != null) { this.m_controls.enabled = true; if (this.m_controls.GetComponent<PlayerIDProvider>().IsLocallyControlled()) { this.m_controls.Motion.SetKinematic(false); } Collider collider = _obj.RequireComponent<Collider>(); collider.enabled = true; this.m_controls.GetComponent<ClientPlayerControlsImpl_Default>().ApplyImpact(this.m_controls.transform.forward.XZ() * 2f, 0.2f); } }
Call
m_cannon.EndCannonRoutine
(note this is on theCannon
, notClientCannon
). This is registered by onlyServerCannon
to callServerCannon.EndCannonRoutine
:public void EndCannonRoutine(GameObject _obj) { this.m_flying = false; IServerCannonHandler handler = this.GetHandler(_obj); if (handler != null) { handler.ExitCannonRoutine(_obj); } }
This handler is the
IServerCannonHandler
, which also has only one implementation:ServerCannonPlayerHandler.ExitCannonRoutine
:public void ExitCannonRoutine(GameObject _obj) { _obj.GetComponent<Rigidbody>().isKinematic = false; GroundCast groundCast = _obj.RequestComponent<GroundCast>(); if (groundCast != null) { groundCast.ForceUpdateNow(); } ServerWorldObjectSynchroniser serverWorldObjectSynchroniser = _obj.RequestComponent<ServerWorldObjectSynchroniser>(); if (serverWorldObjectSynchroniser != null) { serverWorldObjectSynchroniser.ResumeAllClients(false); } }
Start the coroutine from
handler.ExitCannonRoutine
; seeClientCannonPlayerHandler.ExitCannonRoutine
:public IEnumerator ExitCannonRoutine(GameObject _player, Vector3 _exitPosition, Quaternion _exitRotation) { this.m_inCannon = false; this.m_controls.AllowSwitchingWhenDisabled = false; this.m_controls = null; this.m_playerIdProvider = null; if (_player != null) { DynamicLandscapeParenting dynamicParenting = _player.RequestComponent<DynamicLandscapeParenting>(); if (dynamicParenting != null) { dynamicParenting.enabled = true; } yield return null; } if (_player != null) { ClientWorldObjectSynchroniser synchroniser = _player.RequireComponent<ClientWorldObjectSynchroniser>(); while (synchroniser != null && !synchroniser.IsReadyToResume()) { yield return null; } if (synchroniser != null) { synchroniser.Resume(); } } yield break; }
Even though this is a coroutine, it’s really only necessary to resume the
ClientWorldObjectSynchroniser
. The first yield appears unnecessary.
What is GroundCast
?
- Seems to be responsible for handling sticking to the ground. Doesn’t seem all that important to understand it;
just need to call
ForceUpdateNow()
. Also, the client part doesn’t call this, so maybe it’s not necessary here?
What is ServerWorldObjectSynchroniser
and ClientWorldObjectSynchroniser
?
- It’s for physics synchronization. When the chef is in flight it doesn’t need to be synchronized. Afterwards we must re-enable it.
- However, physics synchronization is disabled for local session anyway, so this is probably still not important.
What is DynamicLandscapeParenting
?
- It doesn’t matter what it is… we just need to enable it.
Load process
ServerCannon
’s Load doesn’t really do anything except sending a message. We begin with ClientCannon.Load
.
public void Load(GameObject _obj)
{
this.m_loadedObject = _obj;
this.m_exitPosition = _obj.transform.position;
this.m_exitRotation = _obj.transform.rotation;
IClientCannonHandler handler = this.GetHandler(_obj);
if (handler != null)
{
handler.Load(_obj);
}
_obj.transform.position = this.m_cannon.m_attachPoint.position;
_obj.transform.rotation = this.m_cannon.m_attachPoint.rotation;
_obj.transform.SetParent(this.m_cannon.m_attachPoint, true);
if (this.m_onLoadedCallback != null)
{
this.m_onLoadedCallback(_obj);
}
}
Pretty simple here - sets all the transforms, parents, and calls ClientCannonPlayerHandler.Load
:
public void Load(GameObject _player)
{
this.m_controls = _player.RequireComponent<PlayerControls>();
this.m_controls.AllowSwitchingWhenDisabled = true;
this.m_controls.ThrowIndicator.Hide();
this.m_playerIdProvider = _player.RequireComponent<PlayerIDProvider>();
this.m_inCannon = true;
DynamicLandscapeParenting dynamicLandscapeParenting = _player.RequestComponent<DynamicLandscapeParenting>();
if (dynamicLandscapeParenting != null)
{
dynamicLandscapeParenting.enabled = false;
}
ClientWorldObjectSynchroniser clientWorldObjectSynchroniser = _player.RequestComponent<ClientWorldObjectSynchroniser>();
if (clientWorldObjectSynchroniser != null)
{
clientWorldObjectSynchroniser.Pause();
}
}
It’s mostly the inverse of the end of the launch process.
Unload process
ClientCannon.Unload
:
public IEnumerator Unload(GameObject _obj, Vector3 _exitPosition, Quaternion _exitRotation)
{
if (_obj != null)
{
_obj.transform.position = _exitPosition;
_obj.transform.rotation = _exitRotation;
_obj.transform.SetParent(null, true);
}
this.m_cannon.EndCannonRoutine(_obj);
IClientCannonHandler handler = this.GetHandler(_obj);
if (handler != null)
{
IEnumerator exit = handler.ExitCannonRoutine(_obj, _exitPosition, _exitRotation);
while (exit.MoveNext())
{
yield return null;
}
}
if (this.m_onUnloadedCallback != null)
{
this.m_onUnloadedCallback(_obj);
}
yield break;
}
Basically the same as the launch process except there’s no launching.
4 - Penguin Chess Engine
Penguin Chess is a game that a friend of mine invented.
\(\rightarrow\) Click here to access the game engine! \(\leftarrow\)
The rules are very simple. The game is played on a 5x5 board where each player has 4 pawns and one king. Any piece can move in any of the 8 directions but must move until there is an obstacle (either a piece or the edge of the board). It’s disallowed for a pawn to move to the center square. The game is won when a player moves their king to the center square.
![Starting board](/penguin_board.png)
Originally, the author had the kings and pawns on the same side, but it turns out there’s a 7-move winning strategy by the first player, so as a variant, the kings are swapped. My friend wondered if this is a more interesting game now, so I made an engine to search for any obvious winning moves.
Indeed there aren’t any winning moves in sight. But one must still be very careful because it’s very easy to make a wrong move and lose quickly.
Source Code
The project’s source code is at https://github.com/hpmv/penguin-chess.