first commit

This commit is contained in:
Reid 2023-07-18 22:22:52 -07:00
commit 5302ecc6cc
43 changed files with 2530 additions and 0 deletions

View file

@ -0,0 +1,8 @@
import { Test } from "./components"
/**
* A map of tags to their bound components.
*/
export const tags = new ReadonlyMap([
["Example", Test]
])

View file

@ -0,0 +1,9 @@
import { Transform } from "./types"
/**
* The default value created when no data is provided to a {@link Transform}
* component.
*/
export const transform: Transform = {
cframe: CFrame.identity
}

View file

@ -0,0 +1,64 @@
import { Component, component } from "@rbxts/matter"
import { transform } from "./defaults"
import type {
Model as ModelComponent,
Transform as TransformComponent,
Health as HealthComponent,
Damage as DamageComponent,
PlayerCharacter as PlayerCharacterComponent,
Lifetime as LifetimeComponent,
Input as InputComponent
} from "./types"
export type GooplerComponentType =
| ModelComponent
| TransformComponent
| HealthComponent
| DamageComponent
| PlayerCharacterComponent
| LifetimeComponent
| InputComponent
export type GooplerComponent = Component<GooplerComponentType>
/**
* The {@link ModelComponent | Model} component constructor.
*/
export const Model = component<ModelComponent>("Model")
/**
* The {@link TransformComponent | Transform} component constructor.
*/
export const Transform = component<TransformComponent>("Transform", transform)
/**
* The {@link HealthComponent | Health} component constructor.
*/
export const Health = component<HealthComponent>("Health")
/**
* The {@link DamageComponent | Damage} component constructor.
*/
export const Damage = component<DamageComponent>("Damage")
/**
* The {@link PlayerCharacterComponent | PlayerCharacter} component constructor.
*/
export const PlayerCharacter = component<PlayerCharacterComponent>("PlayerCharacter")
/**
* The {@link LifetimeComponent | Lifetime} component constructor.
*/
export const Lifetime = component<LifetimeComponent>("Lifetime")
/**
* The {@link InputComponent | Input} component constructor.
*/
export const Input = component<InputComponent>("Input")
/**
* This is a test component constructor.
*
* It shouldn't be used and should be removed at some point.
*/
export const Test = component("Test")

View file

@ -0,0 +1,72 @@
/**
* The Model component.
*
* Provides a reference to the {@link PVInstance} that represents the attached
* entity.
*/
export interface Model {
model?: PVInstance
}
/**
* The Transform component.
*
* Provides a reference {@link CFrame} that represents the world transform of
* the attached entity.
*/
export interface Transform {
cframe: CFrame,
_doNotReconcile?: true
}
/**
* The Health component.
*
* Holds health information including health and regeneration.
* Regen is health restored per second.
*/
export interface Health {
health: number,
maxHealth: number,
regeneration: number
}
/**
* The Damage component.
*
* Holds health-reduction information.
*/
export interface Damage {
damage: number
}
/**
* The PlayerCharacter component.
*
* Holds the Humanoid and Player.
*/
export interface PlayerCharacter {
humanoid: Humanoid,
player: Player
}
/**
* The Lifetime component.
*
* Holds the time it was spawned at, how long it should last, and the time elapsed.
*/
export interface Lifetime {
spawnedAt: number,
length: number,
elapsed: number
}
/**
* The input component.
*
* Holds @todo
*/
export interface Input {
test: number
}

View file

@ -0,0 +1,89 @@
import { World, AnyEntity, Loop, Debugger } from "@rbxts/matter"
import Plasma from "@rbxts/plasma"
import { Players, ReplicatedStorage, RunService, UserInputService } from "@rbxts/services"
import { Host } from "ReplicatedStorage/hosts"
import { tags } from "./boundTags"
import { Model } from "./components"
import { start as startReplication, stop as stopReplication } from "./replication"
import { State } from "./state"
import { start as startSystems, stop as stopSystems } from "./systems"
import { start as startTags, stop as stopTags } from "./tags"
const MAX_DISPLAY_ORDER = 2147483647
function authorize(player: Player): boolean {
return RunService.IsStudio() || game.CreatorId === player.UserId
}
let connections:
| {
[index: string]: RBXScriptConnection
}
| undefined
export function start(host: Host): [World, State] {
if (connections) throw "ECS already running."
const state: State = new State()
const world = new World()
const debug = new Debugger(Plasma)
debug.authorize = authorize
debug.findInstanceFromEntity = (id: AnyEntity): Instance | undefined => {
if (!world.contains(id)) return
const model = world.get(id, Model)
return model?.model
}
const loop = new Loop(world, state, debug.getWidgets())
startSystems(host, loop, debug)
debug.autoInitialize(loop)
connections = loop.begin({
default: RunService.Heartbeat,
Stepped: RunService.Stepped
})
if (host === Host.All || host === Host.Server) {
startTags(world, tags)
}
if (host === Host.All || host === Host.Client) {
startReplication(world, state)
const serverDebugger = ReplicatedStorage.FindFirstChild("MatterDebugger")
if (serverDebugger && serverDebugger.IsA("ScreenGui")) {
serverDebugger.DisplayOrder = MAX_DISPLAY_ORDER
}
const clientDebugger = Players.LocalPlayer.FindFirstChild("MatterDebugger")
if (clientDebugger && clientDebugger.IsA("ScreenGui")) {
clientDebugger.DisplayOrder = MAX_DISPLAY_ORDER
}
UserInputService.InputBegan.Connect((input) => {
if (input.KeyCode === Enum.KeyCode.F4 && authorize(Players.LocalPlayer)) {
debug.toggle()
state.debugEnabled = debug.enabled
}
})
}
return [world, state]
}
/**
* Stops the ECS.
*/
export function stop(): void {
if (!connections) return
for (const [_, connection] of pairs(connections)) {
connection.Disconnect()
}
connections = undefined
stopTags()
stopReplication()
stopSystems()
ReplicatedStorage.FindFirstChild("MatterDebuggerRemote")?.Destroy()
}

View file

@ -0,0 +1,104 @@
import { AnyEntity, World } from "@rbxts/matter"
import { waitForEvent } from "ReplicatedStorage/remotes"
import * as Components from "./components"
import { State } from "./state"
type ComponentNames = keyof typeof Components
type ComponentConstructors = (typeof Components)[ComponentNames]
const DEBUG_SPAWN = "Spawn %ds%d with %s"
const DEBUG_DESPAWN = "Despawn %ds%d"
const DEBUG_MODIFY = "Modify %ds%d adding %s, removing %s"
let connection: RBXScriptConnection | undefined
/**
* Starts the replication receiver.
*
* @param world - The world to replicate components in
* @param state - The global state for the ECS
*/
export function start(world: World, state: State): void {
if (connection) return
function debugPrint(message: string, args: () => (string | number)[]): void {
if (state.debugEnabled) {
print("ECS Replication>", string.format(message, ...args()))
}
}
const replicationEvent = waitForEvent("EcsReplication")
const serverToClientEntity = new Map<string, AnyEntity>()
connection = replicationEvent.OnClientEvent.Connect(
(entities: Map<string, Map<ComponentNames, { data?: Components.GooplerComponentType }>>) => {
for (const [serverId, componentMap] of entities) {
const clientId = serverToClientEntity.get(serverId)
if (clientId !== undefined && next(componentMap)[0] === undefined) {
world.despawn(clientId)
serverToClientEntity.delete(serverId)
debugPrint(DEBUG_DESPAWN, () => [clientId, serverId])
continue
}
const componentsToInsert: Components.GooplerComponent[] = []
const componentsToRemove: ComponentConstructors[] = []
const insertNames: ComponentNames[] = []
const removeNames: ComponentNames[] = []
for (const [name, container] of componentMap) {
const component = Components[name]
if (container.data) {
componentsToInsert.push(
// The type of component above is an intersection of all possible
// component types since it can't know which specific component is
// associated with the name. Therefore here, we must cast to an
// intersection so that the data can be used.
//
// This is okay because the data must be associated with the name
// it was created with, but the type checker can't verify this for
// us. To solve this the type must somehow be associated with the
// name in the type system. For now, this cast works fine.
component(container.data as UnionToIntersection<Components.GooplerComponentType>)
)
insertNames.push(name)
} else {
componentsToRemove.push(component)
removeNames.push(name)
}
}
if (clientId === undefined) {
const clientId = world.spawn(...componentsToInsert)
serverToClientEntity.set(serverId, clientId)
debugPrint(DEBUG_SPAWN, () => [clientId, serverId, insertNames.join(",")])
} else {
if (componentsToInsert.size() > 0) {
world.insert(clientId, ...componentsToInsert)
}
if (componentsToRemove.size() > 0) {
world.remove(clientId, ...componentsToRemove)
}
debugPrint(DEBUG_MODIFY, () => [
clientId,
serverId,
insertNames.size() > 0 ? insertNames.join(", ") : "nothing",
removeNames.size() > 0 ? removeNames.join(", ") : "nothing"
])
}
}
}
)
}
/**
* Stops receiving replication.
*/
export function stop(): void {
if (!connection) return
connection.Disconnect()
connection = undefined
}

View file

@ -0,0 +1,8 @@
/**
* The global ECS state.
*/
export class State {
[index: string]: unknown,
// eslint-disable-next-line roblox-ts/no-private-identifier
debugEnabled = false
}

View file

@ -0,0 +1,8 @@
/**
* A test system that does nothing.
*/
function test(): void {
// A test system.
}
export = test

View file

@ -0,0 +1,108 @@
import { AnySystem, Debugger, Loop } from "@rbxts/matter"
import { Context, HotReloader } from "@rbxts/rewire"
import { ServerScriptService } from "@rbxts/services"
import { Host } from "ReplicatedStorage/hosts"
const ERROR_CONTAINER = "%s container not found"
const shared = script.FindFirstChild("shared")
const client = script.FindFirstChild("client")
const server = ServerScriptService.FindFirstChild("goopler")
?.FindFirstChild("ecs")
?.FindFirstChild("systems")
?.FindFirstChild("server")
let firstRunSystems: AnySystem[] | undefined = []
let hotReloader: HotReloader | undefined
/**
* Starts the system loader.
*
* Loads systems for the specified container into the provided loop and
* debugger. Systems are hot reloaded as they are changed.
*
* @param container - The container to load
* @param loop - The ECS loop to load systems into
* @param debug - The debugger to load systems into
*
* @throws "[container] container not found"
* This is thrown when a container necessary for the provided host doesn't
* exist.
*/
export function start<T extends unknown[]>(container: Host, loop: Loop<T>, debug: Debugger): void {
if (!firstRunSystems) return
const containers: Instance[] = []
if (!shared) throw string.format(ERROR_CONTAINER, "Shared")
containers.push(shared)
if (container === Host.All || container === Host.Client) {
if (!client) throw string.format(ERROR_CONTAINER, "Client")
containers.push(client)
}
if (container === Host.All || container === Host.Server) {
if (!server) throw string.format(ERROR_CONTAINER, "Server")
containers.push(server)
}
const systemsByModule: Map<ModuleScript, AnySystem> = new Map()
function load(module: ModuleScript, context: Context): void {
if (
module.Name.match("%.spec$")[0] !== undefined ||
module.Name.match("%.dev$")[0] !== undefined
)
return
const original = context.originalModule
const previous = systemsByModule.get(original)
const [ok, required] = pcall(require, module)
if (!ok) {
warn("Error when hot-reloading system", module.Name, required)
return
}
// Here we don't know that this is necessarily a system, but we let matter
// handle this at runtime.
const system = required as AnySystem
if (firstRunSystems) {
firstRunSystems.push(system)
} else if (previous) {
loop.replaceSystem(previous, system)
debug.replaceSystem(previous, system)
} else {
loop.scheduleSystem(system)
}
systemsByModule.set(original, system)
}
function unload(_: ModuleScript, context: Context): void {
if (context.isReloading) return
const original = context.originalModule
const previous = systemsByModule.get(original)
if (previous) {
loop.evictSystem(previous)
systemsByModule.delete(original)
}
}
hotReloader = new HotReloader()
for (const container of containers) {
hotReloader.scan(container, load, unload)
}
loop.scheduleSystems(firstRunSystems)
firstRunSystems = undefined
}
/**
* Stops loading systems.
*/
export function stop(): void {
if (firstRunSystems) return
firstRunSystems = []
hotReloader?.destroy()
}

View file

@ -0,0 +1,19 @@
import { World, useDeltaTime } from "@rbxts/matter"
import { Lifetime } from "ReplicatedStorage/ecs/components"
/**
* @todo
*/
function updateIdAttribute(world: World): void {
for (const [id, lifetime] of world.query(Lifetime)) {
const newLifetime = lifetime.patch({ elapsed: lifetime.elapsed + useDeltaTime() })
if (os.clock() > lifetime.spawnedAt + lifetime.length) {
world.despawn(id)
} else {
world.insert(id, newLifetime)
}
}
}
export = updateIdAttribute

View file

@ -0,0 +1,25 @@
import { DebugWidgets, World } from "@rbxts/matter"
import { Stats } from "@rbxts/services"
const startInstanceCount = Stats.InstanceCount
const startMemory = Stats.GetTotalMemoryUsageMb()
const startTime = os.clock()
function trackMemory(world: World, state: object, ui: DebugWidgets): void {
const currentInstanceCount = Stats.InstanceCount
const currentMemory = Stats.GetTotalMemoryUsageMb()
ui.window("Memory Stats", () => {
ui.label(`Instances: ${currentInstanceCount}`)
ui.label(`Gained Instances: ${currentInstanceCount - startInstanceCount}`)
ui.label(`Memory: ${string.format("%.1f", currentMemory)}`)
ui.label(`Gained Memory: ${string.format("%.1f", currentMemory - startMemory)}`)
ui.label(`Time: ${string.format("%.1f", os.clock() - startTime)}`)
})
}
export = trackMemory

View file

@ -0,0 +1,16 @@
import { World } from "@rbxts/matter"
import { Model } from "ReplicatedStorage/ecs/components"
import { getIdAttribute } from "ReplicatedStorage/idAttribute"
/**
* A system that updates the ID of {@link Model | Models}.
*
* @param world - The {@link World} the system operates on
*/
function updateIdAttribute(world: World): void {
for (const [id, record] of world.queryChanged(Model)) {
record.new?.model?.SetAttribute(getIdAttribute(), id)
}
}
export = updateIdAttribute

View file

@ -0,0 +1,81 @@
import { AnyEntity, Component, World } from "@rbxts/matter"
import { CollectionService } from "@rbxts/services"
import { getIdAttribute } from "ReplicatedStorage/idAttribute"
import { Model, Transform } from "./components"
export type ComponentConstructor<T extends object> = () => Component<T>
const connections: RBXScriptConnection[] = []
/**
* Starts spawning bound tags.
*
* @param world - The world to spawn components in
* @param bound - A map of bound tags
*/
export function start(
world: World,
bound: ReadonlyMap<string, ComponentConstructor<object>>
): void {
function spawnBound<T extends object>(
instance: Instance,
component: ComponentConstructor<T>
): void {
let primaryPart: BasePart
if (instance.IsA("Model")) {
if (instance.PrimaryPart) {
primaryPart = instance.PrimaryPart
} else {
warn("Attempted to tag a model that has no primary part:", instance)
return
}
} else if (instance.IsA("BasePart")) {
primaryPart = instance
} else {
warn("Attempted to tag an instance that is not a Model or BasePart:", instance)
return
}
const id = world.spawn(
component(),
Model({
model: instance
}),
Transform({
cframe: primaryPart.CFrame
})
)
instance.SetAttribute(getIdAttribute(), id)
}
for (const [tag, component] of bound) {
for (const instance of CollectionService.GetTagged(tag)) {
spawnBound(instance, component)
}
connections.push(
CollectionService.GetInstanceAddedSignal(tag).Connect((instance: Instance): void => {
spawnBound(instance, component)
})
)
connections.push(
CollectionService.GetInstanceRemovedSignal(tag).Connect((instance: Instance): void => {
const id = instance.GetAttribute(getIdAttribute())
if (typeIs(id, "number")) {
world.despawn(id as AnyEntity)
}
})
)
}
}
/**
* Stops spawning bound tags.
*/
export function stop(): void {
for (const connection of connections) {
connection.Disconnect()
}
connections.clear()
}

View file

@ -0,0 +1,24 @@
/**
* Represents a particular host configuration.
*/
export enum Host {
/**
* Represents properties of no particular host.
*/
None,
/**
* Represents properties of the client.
*/
Client,
/**
* Represents properties of the server.
*/
Server,
/**
* Represents properties of all hosts.
*/
All
}

View file

@ -0,0 +1,47 @@
import { Host } from "ReplicatedStorage/hosts"
/**
* A string that represents the default ID attribute when the environment is
* not client or server.
*/
export const unknownIdAttribute = "unknownEntityId"
/**
* A string that represents the ID attribute when the environment is the server.
*/
export const serverIdAttribute = "serverEntityId"
/**
* A string that represents the ID attribute when the environment is the client.
*/
export const clientIdAttribute = "clientEntityId"
let idAttribute = unknownIdAttribute
/**
* Gets a string that represents the current ID attribute being used. This value
* defaults to {@link unknownIdAttribute}.
*
* @return the ID attribute
*/
export function getIdAttribute(): string {
return idAttribute
}
/**
* Sets the `idAttribute` variable based on the provided environment.
*
* @param environment - The environment to set the ID attribute for
*/
export function setEnvironment(environment: Host): void {
switch (environment) {
case Host.Server:
idAttribute = serverIdAttribute
break
case Host.Client:
idAttribute = clientIdAttribute
break
default:
idAttribute = unknownIdAttribute
}
}

View file

@ -0,0 +1,196 @@
const ERROR_MISSING = "%s '%s' was requested, but no such %s exists."
const ERROR_MISSING_EVENT = ERROR_MISSING.format("Event", "%s", "remote event")
const ERROR_MISSING_FUNCTION = ERROR_MISSING.format("Function", "%s", "remote function")
const ERROR_INVALID = "%s '%s' was requested, but was not a %s."
const ERROR_INVALID_EVENT = ERROR_INVALID.format("Event", "%s", "remote event")
const ERROR_INVALID_FUNCTION = ERROR_INVALID.format("Function", "%s", "remote function")
const events = new Map<string, RemoteEvent>()
const functions = new Map<string, RemoteFunction>()
/**
* Gets a remote event by name. If the event doesn't exist it is created.
*
* @param name - The name of the event
* @returns The remote event associated with the name
*/
export function getEvent(name: string): RemoteEvent {
let event = events.get(name)
if (!event) {
const instance = script.FindFirstChild(name)
if (instance && instance.IsA("RemoteEvent")) {
event = instance
} else {
event = new Instance("RemoteEvent")
event.Name = name
event.Parent = script
}
events.set(name, event)
}
return event
}
/**
* Gets a remote event by name.
*
* @param name - The name of the event
* @returns The remote event associated with the name
*
* @throws "Event '[name]' was requested, but no such remote event exists."
* This is thrown when an event name is provided that doesn't exist. If this is
* not desired, see {@link getEvent} and {@link waitForEvent} instead.
*
* @throws "Event '[name]' was requested, but was not a remote event."
* This is thrown when an event name provided exists, but is not an event.
* Usually this is because it's a function instead.
*/
export function getEventOrFail(name: string): RemoteEvent {
let event = events.get(name)
if (!event) {
const instance = script.FindFirstChild(name)
if (!instance) {
throw ERROR_MISSING_EVENT.format(name)
}
if (!instance.IsA("RemoteEvent")) {
throw ERROR_INVALID_EVENT.format(name)
}
event = instance
events.set(name, event)
}
return event
}
/**
* Gets a remote event by name, waiting for it if it doesn't exist.
*
* @param name - The name of the event
* @param timeout - The time to wait for the event
* @returns The remote event associated with the name
*
* @throws "Event '[name]' was requested, but was not a remote event."
* This is thrown when an event name provided exists, but is not an event.
* Usually this is because it's a function instead.
*/
export function waitForEvent(name: string): RemoteEvent
export function waitForEvent(name: string, timeout: number): RemoteEvent | undefined
export function waitForEvent(name: string, timeout?: number): RemoteEvent | undefined {
let event = events.get(name)
if (!event) {
const instance =
timeout !== undefined ? script.WaitForChild(name, timeout) : script.WaitForChild(name)
if (!instance) return
if (!instance.IsA("RemoteEvent")) {
throw ERROR_INVALID_EVENT.format(name)
}
event = instance
events.set(name, event)
}
return event
}
/**
* Destroys the event associated with a name.
*
* @param name - The name of the event
*/
export function destroyEvent(name: string): void {
const event = events.get(name)
if (event) {
event.Destroy()
events.delete(name)
}
}
/**
* Gets a remote function by name. If the function doesn't exist it is created.
*
* @param name - The name of the function
* @returns The remote function associated with the name
*/
export function getFunction(name: string): RemoteFunction {
let fn = functions.get(name)
if (!fn) {
const instance = script.FindFirstChild(name)
if (instance && instance.IsA("RemoteFunction")) {
fn = instance
} else {
fn = new Instance("RemoteFunction")
fn.Name = name
fn.Parent = script
}
functions.set(name, fn)
}
return fn
}
/**
* Gets a remote function by name.
*
* @param name - The name of the function
* @returns The remote function associated with the name
*
* @throws "Function '[name]' was requested, but no such remote function exists."
* This is thrown when a function name is provided that doesn't exist. If this
* is not desired, see {@link getFunction} and {@link waitForFunction} instead.
*
* @throws "Function '[name]' was requested, but was not a remote function."
* This is thrown when a function name provided exists, but is not a function.
* Usually this is because it's an event instead.
*/
export function getFunctionOrFail(name: string): RemoteFunction {
let fn = functions.get(name)
if (!fn) {
const instance = script.FindFirstChild(name)
if (!instance) {
throw ERROR_MISSING_FUNCTION.format(name)
}
if (!instance.IsA("RemoteFunction")) {
throw ERROR_INVALID_FUNCTION.format(name)
}
fn = instance
functions.set(name, fn)
}
return fn
}
/**
* Gets a remote function by name, waiting for it if it doesn't exist.
*
* @param name - The name of the function
* @param timeout - The time to wait for the function
* @returns The remote function associated with the name
*
* @throws "Function '[name]' was requested, but was not a remote function."
* This is thrown when a function name provided exists, but is not a function.
* Usually this is because it's an event instead.
*/
export function waitForFunction(name: string): RemoteFunction
export function waitForFunction(name: string, timeout: number): RemoteFunction | undefined
export function waitForFunction(name: string, timeout?: number): RemoteFunction | undefined {
let fn = functions.get(name)
if (!fn) {
const instance =
timeout !== undefined ? script.WaitForChild(name, timeout) : script.WaitForChild(name)
if (!instance) return
if (!instance.IsA("RemoteFunction")) {
throw ERROR_INVALID_FUNCTION.format(name)
}
fn = instance
functions.set(name, fn)
}
return fn
}
/**
* Destroys the function associated with a name.
*
* @param name - The name of the function
*/
export function destroyFunction(name: string): void {
const fn = functions.get(name)
if (fn) {
fn.Destroy()
functions.delete(name)
}
}

View file

@ -0,0 +1,21 @@
import { World } from "@rbxts/matter"
import { Damage, Health } from "ReplicatedStorage/ecs/components"
/**
* A system that reduces health when Damage components are applied.
*
* This only adjusts any attached Health component.
*/
function damageHurts(world: World): void {
for (const [id, health, damage] of world.query(Health, Damage)) {
world.insert(
id,
health.patch({
health: health.health - damage.damage
})
)
world.remove(id, Damage)
}
}
export = damageHurts

View file

@ -0,0 +1,37 @@
import { World } from "@rbxts/matter"
import { Health, Model } from "ReplicatedStorage/ecs/components"
/**
* A system that mirrors health to Humanoids.
*
* If a health component has an attached model that contains a humanoid. This
* will reflect the changes in health to that humanoid.
*
* If the health is out of bounds between 0 and the max health it is clamped
* between these values.
*/
function healthKills(world: World): void {
for (const [id, record] of world.queryChanged(Health)) {
if (!record.new) continue
const health = math.clamp(record.new.health, 0, record.new.maxHealth)
if (health !== record.new.health) {
world.insert(
id,
record.new.patch({
health: health
})
)
}
const model = world.get(id, Model)
if (!model) continue
const humanoid = model.model?.FindFirstChildOfClass("Humanoid")
if (humanoid) {
humanoid.MaxHealth = record.new.maxHealth
humanoid.Health = health
}
}
}
export = healthKills

View file

@ -0,0 +1,24 @@
import { useThrottle, World } from "@rbxts/matter"
import { Health } from "ReplicatedStorage/ecs/components"
/**
* @todo
*/
function healthRegenerates(world: World): void {
for (const [id, health] of world.query(Health)) {
if (health.health <= 0) continue
const newHealth = math.clamp(health.health + health.regeneration, 0, health.maxHealth)
if (useThrottle(1)) {
world.insert(
id,
health.patch({
health: newHealth
})
)
}
}
}
export = healthRegenerates

View file

@ -0,0 +1,33 @@
import { Players } from "@rbxts/services"
import { World, useEvent } from "@rbxts/matter"
import { Health, Model, PlayerCharacter } from "ReplicatedStorage/ecs/components"
/**
* @todo
*/
function playersArePlayerCharacters(world: World): void {
Players.GetPlayers().forEach((player, _) => {
for (const [_, character] of useEvent(player, "CharacterAdded")) {
world.spawn(
Model({
model: character
}),
PlayerCharacter({
player: Players.GetPlayerFromCharacter(character) as Player,
humanoid: character.WaitForChild("Humanoid") as Humanoid
}),
Health({
health: 80,
maxHealth: 100,
regeneration: 0.25
})
)
}
})
for (const [id] of world.query(PlayerCharacter).without(Model)) {
world.despawn(id)
}
}
export = playersArePlayerCharacters

View file

@ -0,0 +1,41 @@
import { World, useEvent } from "@rbxts/matter"
import { Model, PlayerCharacter } from "ReplicatedStorage/ecs/components"
/**
* @todo
*/
function playersRagdollOnDeath(world: World): void {
for (const [_, playerCharacter, model] of world.query(PlayerCharacter, Model)) {
if (!model.model) continue
playerCharacter.humanoid.BreakJointsOnDeath = false
for (const [_] of useEvent(playerCharacter.humanoid, "Died")) {
model.model.GetDescendants().forEach((v, _) => {
if (v.IsA("Motor6D")) {
const attachment0 = new Instance("Attachment")
const attachment1 = new Instance("Attachment")
attachment0.CFrame = v.C0
attachment1.CFrame = v.C1
attachment0.Parent = v.Part0
attachment1.Parent = v.Part1
const ballSocketConstraint = new Instance("BallSocketConstraint")
ballSocketConstraint.Attachment0 = attachment0
ballSocketConstraint.Attachment1 = attachment1
ballSocketConstraint.LimitsEnabled = true
ballSocketConstraint.TwistLimitsEnabled = true
ballSocketConstraint.Parent = v.Parent
v.Destroy()
}
})
}
}
for (const [id] of world.query(PlayerCharacter).without(Model)) {
world.despawn(id)
}
}
export = playersRagdollOnDeath

View file

@ -0,0 +1,30 @@
import { useEvent, World } from "@rbxts/matter"
import { Model } from "ReplicatedStorage/ecs/components"
/**
* A system that removes missing {@link Model | Models}.
*
* If a model is removed from the game, this system will remove the
* corresponding model from the world.
*
* If a model is removed from the world, this system will remove the
* corresponding model from the game.
*/
function removeMissingModels(world: World): void {
for (const [id, model] of world.query(Model)) {
if (!model.model) continue
for (const _ of useEvent(model.model, "AncestryChanged")) {
if (!model.model.IsDescendantOf(game)) {
world.remove(id, Model)
break
}
}
}
for (const [_, record] of world.queryChanged(Model)) {
if (record.new) continue
record.old?.model?.Destroy()
}
}
export = removeMissingModels

View file

@ -0,0 +1,83 @@
import { useEvent, World } from "@rbxts/matter"
import { Players } from "@rbxts/services"
import * as Components from "ReplicatedStorage/ecs/components"
import { getEvent } from "ReplicatedStorage/remotes"
type ComponentName = keyof typeof Components
type ComponentConstructor = (typeof Components)[ComponentName]
const REPLICATED_COMPONENT_NAMES: readonly ComponentName[] = ["Model", "Health"]
const replicatedComponents: ReadonlySet<ComponentConstructor> = REPLICATED_COMPONENT_NAMES.reduce(
(set: Set<ComponentConstructor>, name: ComponentName) => {
return set.add(Components[name])
},
new Set()
)
getEvent("EcsReplication")
function replication(world: World): void {
const replicationEvent = getEvent("EcsReplication")
let payload: Map<string, Map<ComponentName, { data?: Components.GooplerComponent }>> | undefined
for (const [_, player] of useEvent(Players, "PlayerAdded")) {
if (!payload) {
payload = new Map()
for (const [id, entityData] of world) {
const entityPayload: Map<ComponentName, { data?: Components.GooplerComponent }> =
new Map()
payload.set(tostring(id), entityPayload)
for (const [component, componentData] of entityData) {
if (replicatedComponents.has(component)) {
// Here we are certain that the component has the name of one of our
// components because it exists in our set of components.
entityPayload.set(tostring(component) as ComponentName, { data: componentData })
}
}
}
}
print("Sending initial payload to", player)
replicationEvent.FireClient(player, payload)
}
const changes: Map<
string,
Map<ComponentName, { data?: Components.GooplerComponent }>
> = new Map()
for (const component of replicatedComponents) {
// Here we are certain that the component has the name of one of our
// components since it came from our set.
const name = tostring(component) as ComponentName
for (const [id, record] of world.queryChanged(component)) {
const key = tostring(id)
if (!changes.has(key)) {
changes.set(key, new Map())
}
if (world.contains(id)) {
changes.get(key)?.set(name, { data: record.new })
}
}
}
if (!changes.isEmpty()) {
replicationEvent.FireAllClients(changes)
}
}
/**
* A system that replicates all replicated components to the client.
*
* This system runs after after all other systems to ensure any changes made are
* included.
*/
export = {
system: replication,
priority: math.huge
}

View file

@ -0,0 +1,71 @@
import { World } from "@rbxts/matter"
import { Workspace } from "@rbxts/services"
import { Model, Transform } from "ReplicatedStorage/ecs/components"
import removeMissingModels from "./removeMissingModels"
function updateTransforms(world: World): void {
for (const [id, record] of world.queryChanged(Transform)) {
if (!world.contains(id)) continue
const model = world.get(id, Model)
if (!model || !record.new || record.new._doNotReconcile) continue
model.model?.PivotTo(record.new.cframe)
}
for (const [id, record] of world.queryChanged(Model)) {
if (!world.contains(id)) continue
const transform = world.get(id, Transform)
if (!transform) continue
record.new?.model?.PivotTo(transform.cframe)
}
for (const [id, model, transform] of world.query(Model, Transform)) {
if (!model.model) continue
let primaryPart: BasePart
if (model.model.IsA("Model")) {
if (!model.model.PrimaryPart) continue
primaryPart = model.model.PrimaryPart
} else if (model.model.IsA("BasePart")) {
primaryPart = model.model
} else {
continue
}
if (primaryPart.Anchored) continue
if (transform.cframe.Y < Workspace.FallenPartsDestroyHeight) {
world.despawn(id)
continue
}
if (transform.cframe !== primaryPart.CFrame) {
world.insert(
id,
Transform({
cframe: primaryPart.CFrame,
_doNotReconcile: true
})
)
}
}
}
/**
* A system that updates {@link Transform | Transforms}.
*
* If a Transform is updated, the corresponding {@link Model} is updated to
* match the Transform.
*
* If a Model is updated, the new referenced instance is updated to match the
* Transform.
*
* If an non-anchored model moves, the Transform is updated to match the updated
* instance transform.
*
* This system runs after {@link removeMissingModels} to ensure updates aren't
* performed unnecessarily.
*/
export = {
system: updateTransforms,
after: [removeMissingModels]
}

View file

@ -0,0 +1,13 @@
import { start } from "ReplicatedStorage/ecs"
import { Host } from "ReplicatedStorage/hosts"
import { setEnvironment } from "ReplicatedStorage/idAttribute"
import { getEvent } from "ReplicatedStorage/remotes"
const HOST = Host.Server
// We only do this here at the moment to create a dummy event for replication.
// In the future this will be created by the replication system.
getEvent("EcsReplication")
setEnvironment(HOST)
start(HOST)

View file

@ -0,0 +1,3 @@
// This file exists to disable the roblox health regeneration
// This seems hacky to override something, but this is officially endorsed!
// See: https://create.roblox.com/docs/reference/engine/classes/Humanoid#Health

View file

@ -0,0 +1,8 @@
import { start } from "ReplicatedStorage/ecs"
import { Host } from "ReplicatedStorage/hosts"
import { setEnvironment } from "ReplicatedStorage/idAttribute"
const HOST = Host.Client
setEnvironment(HOST)
start(HOST)