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)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue