first commit
This commit is contained in:
commit
5302ecc6cc
43 changed files with 2530 additions and 0 deletions
8
src/ReplicatedStorage/ecs/boundTags.ts
Normal file
8
src/ReplicatedStorage/ecs/boundTags.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { Test } from "./components"
|
||||
|
||||
/**
|
||||
* A map of tags to their bound components.
|
||||
*/
|
||||
export const tags = new ReadonlyMap([
|
||||
["Example", Test]
|
||||
])
|
9
src/ReplicatedStorage/ecs/components/defaults.ts
Normal file
9
src/ReplicatedStorage/ecs/components/defaults.ts
Normal 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
|
||||
}
|
64
src/ReplicatedStorage/ecs/components/index.ts
Normal file
64
src/ReplicatedStorage/ecs/components/index.ts
Normal 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")
|
72
src/ReplicatedStorage/ecs/components/types.d.ts
vendored
Normal file
72
src/ReplicatedStorage/ecs/components/types.d.ts
vendored
Normal 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
|
||||
}
|
89
src/ReplicatedStorage/ecs/index.ts
Normal file
89
src/ReplicatedStorage/ecs/index.ts
Normal 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()
|
||||
}
|
104
src/ReplicatedStorage/ecs/replication.ts
Normal file
104
src/ReplicatedStorage/ecs/replication.ts
Normal 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
|
||||
}
|
8
src/ReplicatedStorage/ecs/state.ts
Normal file
8
src/ReplicatedStorage/ecs/state.ts
Normal 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
|
||||
}
|
8
src/ReplicatedStorage/ecs/systems/client/test.ts
Normal file
8
src/ReplicatedStorage/ecs/systems/client/test.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* A test system that does nothing.
|
||||
*/
|
||||
function test(): void {
|
||||
// A test system.
|
||||
}
|
||||
|
||||
export = test
|
108
src/ReplicatedStorage/ecs/systems/index.ts
Normal file
108
src/ReplicatedStorage/ecs/systems/index.ts
Normal 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()
|
||||
}
|
19
src/ReplicatedStorage/ecs/systems/shared/lifetimesExpire.ts
Normal file
19
src/ReplicatedStorage/ecs/systems/shared/lifetimesExpire.ts
Normal 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
|
25
src/ReplicatedStorage/ecs/systems/shared/trackMemory.ts
Normal file
25
src/ReplicatedStorage/ecs/systems/shared/trackMemory.ts
Normal 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
|
|
@ -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
|
81
src/ReplicatedStorage/ecs/tags.ts
Normal file
81
src/ReplicatedStorage/ecs/tags.ts
Normal 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()
|
||||
}
|
24
src/ReplicatedStorage/hosts.ts
Normal file
24
src/ReplicatedStorage/hosts.ts
Normal 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
|
||||
}
|
47
src/ReplicatedStorage/idAttribute.ts
Normal file
47
src/ReplicatedStorage/idAttribute.ts
Normal 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
|
||||
}
|
||||
}
|
196
src/ReplicatedStorage/remotes.ts
Normal file
196
src/ReplicatedStorage/remotes.ts
Normal 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)
|
||||
}
|
||||
}
|
21
src/ServerScriptService/ecs/systems/server/damageHurts.ts
Normal file
21
src/ServerScriptService/ecs/systems/server/damageHurts.ts
Normal 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
|
37
src/ServerScriptService/ecs/systems/server/healthKills.ts
Normal file
37
src/ServerScriptService/ecs/systems/server/healthKills.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
83
src/ServerScriptService/ecs/systems/server/replication.ts
Normal file
83
src/ServerScriptService/ecs/systems/server/replication.ts
Normal 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
|
||||
}
|
|
@ -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]
|
||||
}
|
13
src/ServerScriptService/main.server.ts
Normal file
13
src/ServerScriptService/main.server.ts
Normal 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)
|
|
@ -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
|
8
src/StarterPlayer/StarterPlayerScripts/main.client.ts
Normal file
8
src/StarterPlayer/StarterPlayerScripts/main.client.ts
Normal 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)
|
Loading…
Add table
Add a link
Reference in a new issue