Skip to content

Commit

Permalink
First commit
Browse files Browse the repository at this point in the history
  • Loading branch information
petergmcneill committed Jan 15, 2022
0 parents commit 1ca5594
Show file tree
Hide file tree
Showing 30 changed files with 2,792 additions and 0 deletions.
Binary file added DataModel/Lighting.rbxm
Binary file not shown.
Binary file added DataModel/Players.rbxm
Binary file not shown.
Binary file added DataModel/ReplicatedStorage.rbxm
Binary file not shown.
Binary file not shown.
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
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
Loading

0 comments on commit 1ca5594

Please sign in to comment.