-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 1ca5594
Showing
30 changed files
with
2,792 additions
and
0 deletions.
There are no files selected for viewing
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
234 changes: 234 additions & 0 deletions
234
DataModel/ReplicatedStorage/Packages/Chickynoid/Client/ClientCharacter.lua
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,234 @@ | ||
--!strict | ||
|
||
--[=[ | ||
@class ClientCharacter | ||
@client | ||
A character class that handles character rendering and other tasks on the | ||
client. Designed to handle characters for the local player and other players. | ||
]=] | ||
|
||
local UserInputService = game:GetService("UserInputService") | ||
local Players = game:GetService("Players") | ||
|
||
local ClientTransport = require(script.Parent.ClientTransport) | ||
local Simulation = require(script.Parent.Parent.Simulation) | ||
|
||
local Types = require(script.Parent.Parent.Types) | ||
local Enums = require(script.Parent.Parent.Enums) | ||
local EventType = Enums.EventType | ||
|
||
local Camera = workspace.CurrentCamera | ||
|
||
local LocalPlayer = Players.LocalPlayer | ||
local PlayerModule = LocalPlayer:WaitForChild("PlayerScripts"):WaitForChild("PlayerModule") | ||
local ControlModule = require(PlayerModule:WaitForChild("ControlModule")) | ||
|
||
local DebugParts = Instance.new("Folder") | ||
DebugParts.Name = "DebugParts" | ||
DebugParts.Parent = workspace | ||
|
||
local SKIP_RESIMULATION = true | ||
local DEBUG_SPHERES = false | ||
local PRINT_NUM_CASTS = false | ||
|
||
local ClientCharacter = {} | ||
ClientCharacter.__index = ClientCharacter | ||
|
||
--[=[ | ||
Constructs a new ClientCharacter for a player, spawning it at the specified | ||
position. | ||
@param player Player -- The player this character belongs to. Used to derive its [HumanoidDescription] and other things. | ||
@param position Vector3 -- The position to spawn this character, provided by the server. | ||
@return ClientCharacter | ||
]=] | ||
function ClientCharacter.new(player: Player, position: Vector3, config: Types.IClientConfig) | ||
local self = setmetatable({ | ||
_player = player, | ||
_simulation = Simulation.new(config.simulationConfig), | ||
|
||
_predictedCommands = {}, | ||
_stateCache = {}, | ||
|
||
_localFrame = 0, | ||
}, ClientCharacter) | ||
|
||
self._simulation.pos = position | ||
self._simulation.whiteList = { workspace.GameArea, workspace.Terrain } | ||
|
||
if player == LocalPlayer then | ||
self:_handleLocalPlayer() | ||
end | ||
|
||
return self | ||
end | ||
|
||
function ClientCharacter:_handleLocalPlayer() | ||
-- Bind the camera | ||
Camera.CameraSubject = self._simulation.debugModel | ||
Camera.CameraType = Enum.CameraType.Custom | ||
end | ||
|
||
function ClientCharacter:_makeCommand(dt: number) | ||
local command = {} | ||
command.l = self._localFrame | ||
|
||
command.x = 0 | ||
command.y = 0 | ||
command.z = 0 | ||
command.deltaTime = dt | ||
|
||
local moveVector = ControlModule:GetMoveVector() :: Vector3 | ||
if moveVector.Magnitude > 0 then | ||
moveVector = moveVector.Unit | ||
command.x = moveVector.X | ||
command.y = moveVector.Y | ||
command.z = moveVector.Z | ||
end | ||
|
||
-- This approach isn't ideal but it's the easiest right now | ||
if not UserInputService:GetFocusedTextBox() then | ||
command.y = UserInputService:IsKeyDown(Enum.KeyCode.Space) and 1 or 0 | ||
end | ||
|
||
local rawMoveVector = self:_calculateRawMoveVector(Vector3.new(command.x, 0, command.z)) | ||
command.x = rawMoveVector.X | ||
command.z = rawMoveVector.Z | ||
|
||
return command | ||
end | ||
|
||
function ClientCharacter:_calculateRawMoveVector(cameraRelativeMoveVector: Vector3) | ||
local _, yaw = Camera.CFrame:ToEulerAnglesYXZ() | ||
return CFrame.fromEulerAnglesYXZ(0, yaw, 0) * Vector3.new(cameraRelativeMoveVector.X, 0, cameraRelativeMoveVector.Z) | ||
end | ||
|
||
--[=[ | ||
The server sends each client an updated world state on a fixed timestep. This | ||
handles state updates for this character. | ||
@param state table -- The new state sent by the server. | ||
@param lastConfirmed number -- The serial number of the last command confirmed by the server. | ||
]=] | ||
function ClientCharacter:HandleNewState(state: table, lastConfirmed: number) | ||
self:_clearDebugSpheres() | ||
|
||
-- Build a list of the commands the server has not confirmed yet | ||
local remainingCommands = {} | ||
for _, cmd in pairs(self._predictedCommands) do | ||
-- event.lastConfirmed = serial number of last confirmed command by server | ||
if cmd.l > lastConfirmed then | ||
-- Server hasn't processed this yet | ||
table.insert(remainingCommands, cmd) | ||
end | ||
end | ||
self._predictedCommands = remainingCommands | ||
|
||
local resimulate = true | ||
|
||
-- Check to see if we can skip simulation | ||
if SKIP_RESIMULATION then | ||
local record = self._stateCache[lastConfirmed] | ||
if record then | ||
-- This is the state we were in, if the server agrees with this, we dont have to resim | ||
if (record.state.pos - state.pos).magnitude < 0.01 and (record.state.vel - state.vel).magnitude < 0.01 then | ||
resimulate = false | ||
-- print("skipped resim") | ||
end | ||
end | ||
|
||
-- Clear all the ones older than lastConfirmed | ||
for key, _ in pairs(self._stateCache) do | ||
if key < lastConfirmed then | ||
self._stateCache[key] = nil | ||
end | ||
end | ||
end | ||
|
||
if resimulate == true then | ||
print("resimulating") | ||
|
||
-- Record our old state | ||
local oldPos = self._simulation.pos | ||
|
||
-- Reset our base simulation to match the server | ||
self._simulation:ReadState(state) | ||
|
||
-- Marker for where the server said we were | ||
self:_spawnDebugSphere(self._simulation.pos, Color3.fromRGB(255, 170, 0)) | ||
|
||
-- Resimulate all of the commands the server has not confirmed yet | ||
-- print("winding forward", #remainingCommands, "commands") | ||
for _, cmd in pairs(remainingCommands) do | ||
self._simulation:ProcessCommand(cmd) | ||
|
||
-- Resimulated positions | ||
self:_spawnDebugSphere(self._simulation.pos, Color3.fromRGB(255, 255, 0)) | ||
end | ||
|
||
-- Did we make a misprediction? We can tell if our predicted position isn't the same after reconstructing everything | ||
local delta = oldPos - self._simulation.pos | ||
if delta.magnitude > 0.01 then | ||
print("Mispredict:", delta) | ||
end | ||
end | ||
end | ||
|
||
function ClientCharacter:Heartbeat(dt: number) | ||
self._localFrame += 1 | ||
|
||
-- Read user input | ||
local cmd = self:_makeCommand(dt) | ||
table.insert(self._predictedCommands, cmd) | ||
|
||
-- Step this frame | ||
self._simulation:ProcessCommand(cmd) | ||
|
||
-- Marker for positions added since the last server update | ||
self:_spawnDebugSphere(self._simulation.pos, Color3.fromRGB(44, 140, 39)) | ||
|
||
if SKIP_RESIMULATION then | ||
-- Add to our state cache, which we can use for skipping resims | ||
local cacheRecord = {} | ||
cacheRecord.l = cmd.l | ||
cacheRecord.state = self._simulation:WriteState() | ||
|
||
self._stateCache[cmd.l] = cacheRecord | ||
end | ||
|
||
-- Pass to server | ||
ClientTransport:QueueEvent(EventType.Command, { | ||
command = cmd, | ||
}) | ||
ClientTransport:Flush() | ||
|
||
if PRINT_NUM_CASTS then | ||
print("casts", self._simulation.sweepModule.raycastsThisFrame) | ||
end | ||
self._simulation.sweepModule.raycastsThisFrame = 0 | ||
end | ||
|
||
function ClientCharacter:_spawnDebugSphere(pos, color) | ||
if DEBUG_SPHERES then | ||
local part = Instance.new("Part") | ||
part.Anchored = true | ||
part.Color = color | ||
part.Shape = Enum.PartType.Ball | ||
part.Size = Vector3.new(5, 5, 5) | ||
part.Position = pos | ||
part.Transparency = 0.25 | ||
part.TopSurface = Enum.SurfaceType.Smooth | ||
part.BottomSurface = Enum.SurfaceType.Smooth | ||
|
||
part.Parent = DebugParts | ||
end | ||
end | ||
|
||
function ClientCharacter:_clearDebugSpheres() | ||
if DEBUG_SPHERES then | ||
DebugParts:ClearAllChildren() | ||
end | ||
end | ||
|
||
return ClientCharacter |
121 changes: 121 additions & 0 deletions
121
DataModel/ReplicatedStorage/Packages/Chickynoid/Client/ClientTransport.lua
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
--!strict | ||
|
||
--[=[ | ||
@class ClientTransport | ||
@private | ||
@client | ||
Handles communication between the server and client through Event objects. | ||
Unlike the [ServerTransport], the [ClientTransport] is a singleton. Only | ||
one Transport exists on the client and it is consumed by multiple | ||
different modules. | ||
TODO: In the future this should be implemented with some kind of BitBuffer | ||
to minimize network usage. | ||
]=] | ||
|
||
local ReplicatedStorage = game:GetService("ReplicatedStorage") | ||
|
||
local Vendor = script.Parent.Parent:WaitForChild("Vendor") | ||
local Signal = require(Vendor:WaitForChild("Signal")) | ||
|
||
local REMOTE_NAME = "Chickynoid_Replication" | ||
local CachedRemote: RemoteEvent | ||
|
||
local ClientTransport = {} | ||
ClientTransport.OnEventReceived = Signal.new() | ||
ClientTransport._eventQueue = {} | ||
|
||
--[=[ | ||
Attaches connection to the remote so we can listen for events from the server locally. | ||
Also caches the replication remote before any consumers of [ClientTransport] try to use | ||
it. This is required to prevent unexpected yielding or errors when Transport methods | ||
rely on the remote. | ||
@error "Remote cannot be found" -- Thrown when the client cannot find a remote after waiting for it for some period of time. | ||
@yields | ||
]=] | ||
function ClientTransport:PrepareRemote() | ||
local remote = self:_getRemoteEvent(true) | ||
remote.OnClientEvent:Connect(function(events) | ||
for _, event in ipairs(events) do | ||
self.OnEventReceived:Fire(event) | ||
end | ||
end) | ||
end | ||
|
||
--[=[ | ||
Inserts a new event into the queue along with the event type to be handled by the server. | ||
@param eventType number -- Numeric ID of the event, this should be an enum. | ||
@param event table -- The event object. | ||
]=] | ||
function ClientTransport:QueueEvent(eventType: number?, event: table) | ||
table.insert(self._eventQueue, { | ||
type = eventType, | ||
data = event, | ||
}) | ||
end | ||
|
||
--[=[ | ||
Constructs a packet from all events in the queue and sends it to the server. | ||
TODO: Currently this implementation is just an array of events. In the future | ||
it should be implemented as a BitBuffer to reduce network usage. | ||
]=] | ||
function ClientTransport:Flush() | ||
local remote = self:_getRemoteEvent() | ||
|
||
-- local eventCount = #self._eventQueue | ||
-- print(("Flushing %s events"):format(eventCount)) | ||
|
||
local packet = self._eventQueue | ||
remote:FireServer(packet) | ||
|
||
table.clear(self._eventQueue) | ||
end | ||
|
||
--[=[ | ||
Calls the passed callback when any event of the specified type is received on | ||
the client. | ||
@param eventType number -- Numeric ID of the event, this should be an enum. | ||
@param callback (event: table) -> nil -- Callback that is passed the event object.. | ||
]=] | ||
function ClientTransport:OnEventTypeReceived(eventType: number, callback: (event: table) -> nil) | ||
self.OnEventReceived:Connect(function(event) | ||
if event.type == eventType then | ||
callback(event.data) | ||
end | ||
end) | ||
end | ||
|
||
--[=[ | ||
Gets the replication remote and throws if it cannot be found. This could yield | ||
if no remote is cached, so consumers of [ClientTransport] should cache it before | ||
using the Transport with [ClientTransport:CacheRemote]. | ||
@error "Remote cannot be found" -- Thrown when the client cannot find a remote after waiting for it for some period of time. | ||
@private | ||
@yields | ||
]=] | ||
function ClientTransport:_getRemoteEvent(allowYield: boolean?): RemoteEvent | ||
if CachedRemote then | ||
return CachedRemote | ||
end | ||
|
||
local getMethod = if allowYield | ||
then ReplicatedStorage.WaitForChild | ||
else ReplicatedStorage.FindFirstChild | ||
|
||
local existingRemote = getMethod(ReplicatedStorage, REMOTE_NAME) :: RemoteEvent | ||
if existingRemote then | ||
CachedRemote = existingRemote | ||
return existingRemote | ||
end | ||
|
||
error("Remote cannot be found") | ||
end | ||
|
||
return ClientTransport |
Oops, something went wrong.