├── .gitignore ├── LICENSE.txt ├── Makefile ├── README.md ├── build.project.json ├── config.ld ├── default.project.json ├── doc-templates └── template.md ├── docs.lua ├── docs ├── Modules-256-black.png ├── Modules-256.png ├── Modules-32.png ├── Modules-black.png ├── Modules.svg ├── extra.css ├── font │ └── JetBrainsMono.woff ├── getting-started.md └── index.md ├── ldoc2mkdocs ├── .gitignore ├── LDoc2MkDocs.py ├── __init__.py ├── __main__.py └── filters.py ├── mkdocs.yml ├── requirements-docs.txt ├── roblox.toml ├── selene.toml ├── src ├── Event.lua ├── Maid.lua ├── StateMachine │ ├── State.lua │ └── init.lua ├── class.lua ├── init.lua └── version │ ├── build-meta-head.lua │ └── init.lua ├── test.project.json └── test ├── ReplicatedStorage ├── ModulesTest │ ├── Event.test.lua │ ├── Maid.test.lua │ ├── Modules.test │ │ ├── PlainModule.lua │ │ └── init.lua │ ├── StateMachine.test.lua │ ├── class.test.lua │ └── version.test.lua ├── SomeClientNamespace │ ├── SomeClientModule.WithAPeriod.lua │ └── SomeClientModule.lua └── TestRunner.lua ├── ServerScriptService ├── ModulesTest │ ├── RunTests.server.lua │ └── ServerTests │ │ └── Modules.test.lua └── SomeServerNamespace │ ├── Replicated │ └── SomeReplicatedBit.lua │ └── SomeServerModule.lua └── StarterPlayer └── StarterPlayerScripts ├── ClientTests └── Modules.test.lua └── RunTests.client.lua /.gitignore: -------------------------------------------------------------------------------- 1 | # Roblox files built by Rojo 2 | *.rbxm 3 | *.rbxmx 4 | *.rbxl 5 | *.rbxlx 6 | 7 | # Build metadata which appears in version 8 | src/version/build-meta.lua 9 | 10 | # Roblox place lockfiles 11 | *.lock 12 | 13 | # Sublime Text 14 | *.sublime-workspace 15 | *.sublime-project 16 | 17 | # Visual Studio Code 18 | *.code-workspace 19 | 20 | # Built documentation site 21 | docs-build/* 22 | venv/* 23 | site/* 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ozzypig 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY = src build \ 2 | test src-test \ 3 | clean-all clean \ 4 | clean-src clean-test clean-docs 5 | 6 | # Build metadata 7 | 8 | BUILD_META = 9 | BUILD_META_HEAD_FILE = src/version/build-meta-head.lua 10 | BUILD_META_FILE = src/version/build-meta.lua 11 | 12 | # Rojo 13 | 14 | ROJO = rojo 15 | 16 | OUT_FILE = Modules.rbxmx 17 | ROJO_PROJECT_BUILD = build.project.json 18 | 19 | ROJO_PROJECT_TEST = test.project.json 20 | TEST_FILE = test.rbxlx 21 | 22 | # Documentation 23 | 24 | LDOC_LUA = ldoc 25 | ifeq ($(OS),Windows_NT) 26 | LDOC_LUA = ldoc.lua.bat 27 | endif 28 | 29 | LDOC_CONFIG = config.ld 30 | 31 | PYTHON = python 32 | PIP = $(PYTHON) -m pip 33 | VENV = venv 34 | 35 | DOCS_REQUIREMENTS = requirements-docs.txt 36 | MKDOCS = $(PYTHON) -m mkdocs 37 | MKDOCS_CONFIG = mkdocs.yml 38 | 39 | DOCS_FILTER_LUA_MODULE = docs.filter 40 | DOCS_SRC = docs 41 | DOCS_SITE = site 42 | DOCS_BUILD = docs-build 43 | DOCS_JSON = docs.json 44 | 45 | LDOC2MKDOCS = $(PYTHON) -m ldoc2mkdocs 46 | 47 | # Utilities 48 | 49 | RM = rm 50 | CP = cp 51 | 52 | build : $(OUT_FILE) 53 | 54 | $(OUT_FILE) : src 55 | $(ROJO) build $(ROJO_PROJECT_BUILD) --output Modules.rbxmx 56 | 57 | src : $(wildcard src/**/*) 58 | $(CP) $(BUILD_META_HEAD_FILE) $(BUILD_META_FILE) 59 | $(file >>$(BUILD_META_FILE),$(BUILD_META)) 60 | 61 | test : $(TEST_FILE) 62 | 63 | $(TEST_FILE) : src src-test 64 | $(ROJO) build $(ROJO_PROJECT_TEST) --output $(TEST_FILE) 65 | 66 | src-test : $(wildcard test/**/*) 67 | 68 | venv : 69 | $(PYTHON) -m venv $(VENV) 70 | 71 | docs : clean-docs 72 | mkdir $(DOCS_BUILD) 73 | $(CP) -r $(DOCS_SRC)/* $(DOCS_BUILD) 74 | $(LDOC_LUA) . --config $(LDOC_CONFIG) --filter $(DOCS_FILTER_LUA_MODULE) > $(DOCS_BUILD)/$(DOCS_JSON) 75 | $(LDOC2MKDOCS) $(DOCS_BUILD)/$(DOCS_JSON) $(DOCS_BUILD) --pretty 76 | $(MKDOCS) build --config-file $(MKDOCS_CONFIG) --clean 77 | 78 | clean : clean-src clean-test clean-docs 79 | 80 | clean-src : 81 | $(RM) -f $(OUT_FILE) 82 | 83 | clean-test : 84 | $(RM) -f $(TEST_FILE) 85 | 86 | clean-docs : 87 | $(RM) -rf $(DOCS_BUILD) 88 | $(RM) -rf $(DOCS_SITE) 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modules 2 | 3 | 4 | 5 | > Seriously, another dependency loader for Roblox? –Somebody 6 | 7 | _Modules_ is a simple dependency loader for the [Roblox engine](https://www.roblox.com). It's a single [ModuleScript](https://developer.roblox.com/en-us/api-reference/class/ModuleScript) named "Modules" which exists in [ReplicatedStorage](https://developer.roblox.com/en-us/api-reference/class/ReplicatedStorage), and it is designed to replace the built-in `require` function. 8 | 9 | _[Click here to visit the documentation site for Modules →](https://modules.ozzypig.com)_ 10 | 11 | ## Download & Install 12 | 13 | There's several ways you can get it: 14 | 15 | * _[Take the Model from Roblox.com →](https://www.roblox.com/library/5517888456/Modules-v1-0-0)_ 16 | * _[Download from the GitHub releases page →](https://github.com/Ozzypig/Modules/releases/)_ 17 | * Advanced: build _Modules_ from source using [Rojo 0.5.x](https://github.com/Roblox/rojo) 18 | 19 | Once available, insert the Model into your Roblox place, then move the root "Modules" ModuleScript into ReplicatedStorage. 20 | 21 | _[All ready for blast-off? Check out the Getting Started guide →](https://modules.ozzypig.com/getting-started/)_ 22 | 23 | ## Usage 24 | 25 | Replace `require` with the value returned by the "Modules" (the root ModuleScript). It behaves exactly the same way it did before, but in addition to typical arguments types, you can provide strings: 26 | 27 | ```lua 28 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules")) 29 | 30 | local MyClass = require("MyLibrary:MyClass") 31 | local AnotherClass = require("MyLibrary:Something.AnotherClass") 32 | ``` 33 | 34 | The ModuleLoader looks for a **namespace** [Folder](https://developer.roblox.com/en-us/api-reference/class/Folder) named "MyLibrary" in either [ReplicatedStorage](https://developer.roblox.com/en-us/api-reference/class/ReplicatedStorage) or [ServerScriptService](https://developer.roblox.com/en-us/api-reference/class/ServerScriptService) (if on the server) which contains a ModuleScript named "MyClass". 35 | 36 | ## Some Goodies Included 37 | 38 | There's a few patterns that incredibly useful in most Roblox projects, so they're included as modules. They may be required by not specifying a namespace, eg `require("Event")`. The included modules are: 39 | 40 | - `class`: provides utility functions for working with idomatic Lua classes 41 | - `Event`: class similar to Roblox's built-in [RBXScriptSignal](https://developer.roblox.com/en-us/api-reference/datatype/RBXScriptSignal), it allows any kind of data and has `connect`, `fire`, `wait` methods 42 | - `Maid`: class for deconstructing/deallocating objects; call `addTask` with a connection, function, Instance or other Maid to be disconnected, called or [destroyed](https://developer.roblox.com/en-us/api-reference/function/Instance/Destroy) when `cleanup` is called 43 | - `StateMachine`: a simple implementation of a state machine pattern, event-based or subclass based 44 | - `State`: a single state in a StateMachine 45 | 46 | Each of these modules are documented in-code using [LDoc](https://github.com/stevedonovan/LDoc), which also appears on the [documentation site](https://modules.ozzypig.com/). 47 | 48 | --- 49 | 50 | ## Development of _Modules_ 51 | 52 | This section is for development of _Modules_ itself, not using it to make your own stuff. To learn how to do that, check out the documentation site. The rest of this readme will only pertain to developing _Modules_. 53 | 54 | * To **build** and **test** this project, you need [Rojo 0.5.x](https://github.com/Roblox/rojo) and ideally [GNU Make](https://www.gnu.org/software/make/). 55 | 56 | ### Building 57 | 58 | The [Makefile](Makefile) contains a `build` target, which creates the file Modules.rbxlx. 59 | 60 | ```sh 61 | # Build Modules.rbxlx 62 | $ make build 63 | # In a new place in Roblox Studio, insert this Model into ReplicatedStorage. 64 | # Start syncing build resources using Rojo 65 | $ rojo serve default.project.json 66 | ``` 67 | 68 | Using [build.project.json](build.project.json), invoke Rojo to build `Modules.rbxmx`, a Roblox model file containing only the root ModuleScript (Modules). After it is built and inserted into a Roblox place, you can use the [default.project.json](default.project.json) Rojo project file to sync changes into the already-installed instance. 69 | 70 | ### Documentation 71 | 72 | To build the documentation for this project, you need [Lua 5.1](https://lua.org) and [LDoc](https://github.com/stevedonovan/LDoc) (both of these available in [Lua for Windows](https://github.com/rjpcomputing/luaforwindows)); additionally [Python 3.7](https://www.python.org/) and the libraries in [requirements-docs.txt](requirements-docs.txt), which can be installed easily using [pip](https://pip.pypa.io/en/stable/). 73 | 74 | On a Debian-based operating system, like Ubuntu, you can perhaps use these shell commands to install all the required dependencies: 75 | 76 | ```sh 77 | $ sudo apt update 78 | # Install Lua 5.1, LuaRocks, LuaJson and LDoc 79 | $ sudo apt install lua5.1 80 | $ sudo apt install luarocks 81 | $ sudo luarocks install luajson 82 | $ sudo luarocks install ldoc 83 | # First install Python 3.7. You may have to add the deadsnakes ppa to do this: 84 | $ sudo apt install software-properties-common 85 | $ sudo add-apt-repository ppa:deadsnakes/ppa 86 | $ sudo apt install python3.7 87 | $ sudo apt install python3.7-venv 88 | # Now create the virtual environment, activate it, and install Python dependencies 89 | $ python3.7 -m venv venv 90 | $ source venv/bin/activate 91 | $ pip install -r requirements-docs.txt 92 | # At this point you're good to go! 93 | $ make docs 94 | # Static HTML becomes available in site/ 95 | ``` 96 | 97 | The source for _Modules_ documentation exists right in its [source code](src/) using doc comments, as well as the [docs](docs/) directory. To prepare this for the web, a somewhat roundabout process is taken to building the static web content. The [Makefile](Makefile) contains a `docs` target, which will do the following: 98 | 99 | * Using [LDoc](https://github.com/stevedonovan/LDoc) (Lua 5.1), doc comment data is exported in a raw JSON format. The [docs.lua](docs.lua) script helps with this process by providing a filter function. 100 | * The [ldoc2mkdoc](ldoc2mkdoc/) Python module in this repostory converts the raw JSON to Markdown using the [Jinja2](https://palletsprojects.com/p/jinja/) template engine. 101 | * This Markdown is then passed to [MkDocs](https://www.mkdocs.org/) to build the static website source (HTML). 102 | 103 | ### Testing 104 | 105 | The [Makefile](Makefile) contains a `test` target. It invokes [Rojo 0.5.x](https://github.com/Roblox/rojo) with the [test.project.json](test.project.json) file to build a Roblox place file, test.rbxlx, that runs all tests in Roblox Studio. 106 | 107 | ```sh 108 | # Build test.rbxlx 109 | $ make test 110 | # Start syncing test resources using Rojo 111 | $ rojo serve test.project.json 112 | ``` 113 | 114 | Tests are included in ".test" modules as children of the module they contain tests for. Tests are run using the [TestRunner](test/ReplicatedStorage/TestRunner.lua), which is invoked by [RunTests.server.lua](test/ServerScriptService/ModulesTest/RunTests.server.lua) in "ModuleTests" in ServerScriptService. The TestRunner gathers tests from every ModuleScript whose name ends with ".test". Client tests are run by [RunTests.client.lua](test/StarterPlayer/StarterPlayerScripts/RunTests.client.lua), in [StarterPlayerScripts](https://developer.roblox.com/en-us/api-reference/class/StarterPlayerScripts). 115 | 116 | ## License 117 | 118 | _Modules_ is released under the MIT License, which you can read the complete text in [LICENSE.txt](LICENSE.txt). This means you can use this for commercial purposes, distribute it freely, modify it, and use it privately. 119 | -------------------------------------------------------------------------------- /build.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Modules", 3 | "tree": { 4 | "$path": "src" 5 | } 6 | } -------------------------------------------------------------------------------- /config.ld: -------------------------------------------------------------------------------- 1 | -- Meta 2 | project = "Modules" 3 | description = "Another dependency loader for Roblox" 4 | title = "Modules Documentation" 5 | 6 | -- 7 | format = "markdown" 8 | file = { 9 | "src" 10 | } 11 | dir = "ldoc-build" 12 | 13 | -- 14 | use_markdown_titles = true 15 | no_summary = false 16 | no_space_before_args = true 17 | not_luadoc = true 18 | 19 | -- LDoc extensions 20 | new_type("event", "Events") 21 | new_type("staticfield", "Static Fields", false) 22 | new_type("staticfunction", "Static Functions") 23 | alias("property", "field") 24 | alias("subclass", "see") 25 | alias("private", "local") 26 | 27 | custom_tags = { 28 | {'constructor', title='Constructor', hidden=true}; 29 | {'remark', title='Remark', hidden=false}; 30 | --{'warning',title='Warning',hidden=false}; 31 | {'superclass', title='Superclass', hidden=false}; 32 | {'abstract', title='Abstract', hidden=false}; 33 | } 34 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Modules", 3 | "tree": { 4 | "$className": "DataModel", 5 | "ReplicatedStorage": { 6 | "$className": "ReplicatedStorage", 7 | "$ignoreUnknownInstances": true, 8 | "Modules": { 9 | "$path": "src" 10 | } 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /doc-templates/template.md: -------------------------------------------------------------------------------- 1 | {% macro render_return_list(retvals) -%} 2 | {%- if retvals: -%} 3 | {%- for value in retvals: -%} 4 | {%- if not loop.first: -%}, {%- endif -%} 5 | {%- if 'type' in value: -%} 6 | {{ value['type'] | xref }} 7 | {%- endif -%} 8 | {%- endfor -%} 9 | {%- endif -%} 10 | {%- endmacro -%} 11 | 12 | {%- macro render_params(item) -%} 13 | {%- for param in item['paramsList'] -%} 14 | {%- set isOpt = false %} 15 | {%- if param in item['modifiers']['param'] -%} 16 | {%- set paramModifiers = item['modifiers']['param'][param] -%} 17 | {%- set isOpt = paramModifiers['opt'] -%} 18 | {%- if isOpt -%}\[{%- endif %} 19 | {%- if not loop.first -%}, {% endif -%} 20 | {%- if 'type' in paramModifiers -%} 21 | {%- set paramType = item['modifiers']['param'][param]['type'] -%} 22 | {%- if paramType[0] == '?' -%} 23 | {%- for paramType2 in paramType[1:].split('|') -%} 24 | {%- if not loop.first -%}/{%- endif -%} 25 | {{ paramType2 | xref }} 26 | {%- endfor -%}  27 | {%- else -%} 28 | {{ paramType | xref }}  29 | {%- endif -%} 30 | {%- endif -%} 31 | {%- else -%} 32 | {%- if not loop.first -%}, {% endif -%} 33 | {%- endif -%} 34 | `{{ param }}` 35 | {%- if isOpt -%}\]{%- endif %} 36 | {%- endfor -%} 37 | {%- endmacro -%} 38 | 39 | {%- macro render_item(item) %} 40 | 41 | ### {{ render_return_list(item['modifiers']['return']) }} `{{ item['name'] }}`{%- if item['type'] in ('event', 'function', 'staticfunction') -%}({{ render_params(item) }}){%- endif -%} {{ item['name'] | anchor_here }} 42 | 43 | {%- if item['summary']: %} 44 | 45 | 46 | {{ item['summary'] | trim | ldoc }} 47 | {%- endif -%} 48 | {%- if item['description']: %} 49 | 50 | 51 | {{ item['description'] | trim | ldoc }} 52 | {%- endif -%} 53 | 54 | {%- endmacro -%} 55 | 56 | {%- macro render_type(header, items, types) -%} 57 | {%- for item in entry['items'] if item['type'] in types and not 'constructor' in item['tags'] -%} 58 | {% if loop.first %} 59 | 60 | ## {{ header }} 61 | 62 | {% endif %} 63 | {{ render_item(item) }} 64 | {%- endfor -%} 65 | {%- endmacro -%} 66 | 67 | # `{{ entry['name'] }}` {{ entry['name'] | anchor_here }} 68 | 69 | {{ entry['description'] | ldoc }} 70 | 71 | {%- for item in entry['items'] if item['type'] in 'staticfunction' and 'constructor' in item['tags'] -%} 72 | {% if loop.first %} 73 | 74 | ## Constructors 75 | 76 | {% endif %} 77 | {{ render_item(item) }} 78 | {%- endfor -%} 79 | 80 | {{ render_type('Static Functions', entry['items'], ('staticfunction',))}} 81 | {{ render_type('Fields', entry['items'], ('field','table'))}} 82 | {{ render_type('Functions', entry['items'], ('function',))}} 83 | {{ render_type('Events', entry['items'], ('event',))}} 84 | -------------------------------------------------------------------------------- /docs.lua: -------------------------------------------------------------------------------- 1 | -- This Lua module is invoked by LDoc to save output 2 | -- in JSON format, which is then used by ldoc2mkdoc 3 | 4 | local json = require("json") 5 | 6 | function filterEntryItem(entry, item) 7 | if item["params"] then 8 | local params = item["params"] 9 | local paramsList = {} 10 | local paramsMap = params["map"] or {} 11 | for k, v in pairs(params) do 12 | if tonumber(k) then 13 | paramsList[tonumber(k)] = v 14 | end 15 | end 16 | item["paramsList"] = paramsList 17 | item["paramsMap"] = paramsMap 18 | end 19 | end 20 | 21 | return { 22 | filter = function (t) 23 | for _, entry in pairs(t) do 24 | for __, item in pairs(entry["items"]) do 25 | filterEntryItem(entry, item) 26 | end 27 | end 28 | print(json.encode.encode(t)) 29 | end 30 | } -------------------------------------------------------------------------------- /docs/Modules-256-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ozzypig/Modules/e51d56366e7e3a107e9a478db10f7cb0aa2cb048/docs/Modules-256-black.png -------------------------------------------------------------------------------- /docs/Modules-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ozzypig/Modules/e51d56366e7e3a107e9a478db10f7cb0aa2cb048/docs/Modules-256.png -------------------------------------------------------------------------------- /docs/Modules-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ozzypig/Modules/e51d56366e7e3a107e9a478db10f7cb0aa2cb048/docs/Modules-32.png -------------------------------------------------------------------------------- /docs/Modules-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ozzypig/Modules/e51d56366e7e3a107e9a478db10f7cb0aa2cb048/docs/Modules-black.png -------------------------------------------------------------------------------- /docs/Modules.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /docs/extra.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | list-style: square; 3 | display: list-item; 4 | } 5 | 6 | h2 { 7 | border-bottom: solid 2px rgba(0,0,0,.1); 8 | } 9 | 10 | h3 { 11 | list-style: circle; 12 | display: list-item; 13 | } 14 | 15 | h4 { 16 | padding-left: 2em; 17 | } 18 | 19 | h5 { 20 | padding-left: 2em; 21 | } 22 | 23 | h6 { 24 | padding-left: 2em; 25 | } 26 | 27 | pre, code, kbd { 28 | font-family: "JetBrains Mono", Consolas, monospace; 29 | } 30 | 31 | @font-face { 32 | font-family: "JetBrains Mono"; 33 | src: "font/JetBrainsMono.woff"; 34 | } 35 | -------------------------------------------------------------------------------- /docs/font/JetBrainsMono.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ozzypig/Modules/e51d56366e7e3a107e9a478db10f7cb0aa2cb048/docs/font/JetBrainsMono.woff -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | _Modules_ is designed to be simple and straightforward. 4 | 5 | ## 1. Install _Modules_ 6 | 7 | After inserting _Modules_ into your game, move the root "Modules" ModuleScript to ReplicatedStorage. 8 | 9 |
game
 10 | └ ReplicatedStorage
 11 |   └ Modules
 12 | 
13 | 14 | Anywhere in ReplicatedStorage will work, but it's recommended to be a direct child. 15 | 16 | ## 2. Create Your Namespace 17 | 18 | To make a **namespace**, create a Folder in [ServerScriptService](https://developer.roblox.com/en-us/api-reference/class/ServerScriptService) and/or [ReplicatedStorage](https://developer.roblox.com/en-us/api-reference/class/ReplicatedStorage). The Name of this folder should be distinct like any other object in the hierarchy, and ideally shouldn't contain symbols. 19 | 20 |
game
 21 | ├ ReplicatedStorage
 22 | │ └ MyGame         ← Create this Folder (for client stuff)...
 23 | └ ServerScriptService
 24 |   └ MyGame         ← ...and/or this folder (for server stuff)
 25 | 
26 | 27 | It's recommended you use [Pascal case](https://en.wikipedia.org/wiki/Pascal_case) for the name of your namespace. Examples: `MyLibrary`, `SomeOtherLibrary`, etc 28 | 29 | ## 3. Add ModuleScripts, and Everything Else 30 | 31 | Add whatever ModuleScripts to your namespace folder you like. As with the namespace folder, you should follow a consistent naming scheme for your modules. Avoid non-alphanumeric characters. If a module needs assets, you can include those within it or somewhere else in your namespace folder. 32 | 33 | Consider the following heirarchy, where the contents of `Tools` are ModuleScripts: 34 | 35 |
game
 36 | ├ ReplicatedStorage
 37 | │ ├ Modules
 38 | │ └ MyGame [Folder]
 39 | │   └ Tools [Folder]
 40 | │     ├ Sword
 41 | │     ├ Slingshot
 42 | │     └ Rocket Launcher
 43 | └ ServerScriptService
 44 |   └ MyGame [Folder]
 45 |     └ ServerManager
 46 | 
47 | 48 | ## 4. Require Your ModuleScripts 49 | 50 | A string passed to `require` should first specify a namespace, followed by a colon, then the module. If the Module is within other objects, separate their names with a period. Using the heirarchy above, from (3): 51 | 52 | ```lua 53 | -- Some script in your game: 54 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules")) 55 | 56 | local ServerManager = require("MyGame:ServerManager") 57 | local Sword = require("MyGame:Tools.Sword") 58 | local Slingshot = require("MyGame:Tools.Slingshot") 59 | ``` 60 | 61 | _Modules_ uses the following process to locate the module: 62 | 63 | 1. If no namespace was specified, assume the Modules ModuleScript is the namespace. 64 | 2. Check for client module first: look for the namespace in ReplicatedStorage, if found, check it for the module. 65 | 3. Check for server modules second: look for the namespace in ServerScriptService, if found, check it for the module. If either the namespace or module is missing, raise a "module not found" error. 66 | 67 | If both the client and server require a shared library, but that shared library has a dependency only relevant to either the client or server, you can use `require.server` or `require.client` to skip the require and return nil if the code isn't running on that network peer: 68 | 69 | ```lua 70 | -- Some ModuleScript that is required on both client and server 71 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules")) 72 | 73 | -- Client won't be able to access this 74 | local ServerManager = require.server("MyGame:ServerManager") 75 | 76 | local MySharedLibrary = {} 77 | 78 | -- Perhaps in MySharedLibrary there's a function which is 79 | -- only called by the server, and that function might use ServerManager, 80 | -- but we still want to enable the client to require this module for the other bits. 81 | 82 | return MySharedLibrary 83 | ``` 84 | 85 | This is useful in a variety of cases. Most notably, if constants in a shared class module are needed by both the client and server, constructing the class might only be possible on the server. This could due to certain server dependencies, but using `require.server`, the client can skip over those dependencies since it wouldn't be constructing the object and using those dependencies. 86 | 87 | ## 5. Use a "Replicated" Folder (Optional) 88 | 89 | In some cases, like using [Roblox Packages](https://developer.roblox.com/en-us/articles/roblox-packages), it's desirable to unify all of a namespace's code (both server and client code) into one root object. You can replicate only part of a namespace folder in ServerScriptService using a "Replicated" Folder: 90 | 91 | For namespace Folders in ServerScriptService, you may add a Folder named "Replicated". Such a folder is automatically replicated to clients: the folder is moved to ReplicatedStorage and renamed to the same as the namespace. Therefore, any ModuleScripts inside the Replicated folder can be required as if the Module were placed in ReplicatedStorage in the first place. 92 | 93 | Consider the following game structure, which uses both ServerScriptService and ReplicatedStorage namespace folders. To unify the codebase into one object, we can move the client folder into the server folder and rename it "Replicated". 94 | 95 |
game
 96 | ├ ReplicatedStorage
 97 | │ ├ Modules
 98 | │ └ MyNamespace          ← Rename this "Replicated"...
 99 | │   └ SomeSharedModule
100 | └ ServerScriptService
101 |   └ MyNamespace          ← ...and move it into here.
102 |     └ JustAServerModule
103 | 
104 | 105 | After moving the ReplicatedStorage namespace folder to the ServerScriptService folder, and renaming it to "Replicated", the structure should now look like this: 106 | 107 |
game
108 | ├ ReplicatedStorage
109 | │ └ Modules
110 | └ ServerScriptService
111 |   └ MyNamespace
112 |     ├ JustAServerModule
113 |     └ Replicated         ← When Modules is loaded, this is moved to:
114 |       └ SomeSharedModule   ReplicatedStorage.MyNamespace.SomeSharedModule
115 |                                  (which is what we had before, but now there's
116 |                                  a single root object for all of MyNamespace!)
117 | 
118 | 119 | ## 6. Check out the Goodies 120 | 121 | Modules comes with a few really useful classes you should never leave home without. See [Overview#Structure](index.md#structure) for a list, or check out a few of these: [Event](api/Event.md), [Maid](api/Maid.md), [StateMachine](api/StateMachine.md). 122 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Modules 2 | 3 | Modules logo 4 | 5 | > Seriously, another dependency loader for Roblox? –Somebody, probably 6 | 7 | _Modules_ is a simple dependency loader for the [Roblox engine](https://www.roblox.com). It's a single [ModuleScript](https://developer.roblox.com/en-us/api-reference/class/ModuleScript) named "Modules" which exists in [ReplicatedStorage](https://developer.roblox.com/en-us/api-reference/class/ReplicatedStorage), and it is designed to replace the built-in `require` function. 8 | 9 | ```lua 10 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules")) 11 | 12 | local MyModule = require("MyNamespace:MyModule") 13 | ``` 14 | 15 | _[Visit the Getting Started guide to learn the basics →](getting-started.md)_ 16 | 17 | ## Download & Install 18 | 19 | There's several ways you can get it: 20 | 21 | * _[Take the Model from Roblox.com →](https://www.roblox.com/library/5618924671/Modules-v1-1-0)_ 22 | * _[Download from the GitHub releases page →](https://github.com/Ozzypig/Modules/releases/)_ 23 | * Advanced: build _Modules_ from source using [Rojo 0.5.x](https://github.com/Roblox/rojo) 24 | 25 | Once available, insert the Model into your Roblox place, then move the root "Modules" ModuleScript into ReplicatedStorage. 26 | 27 | ## Structure 28 | 29 | _Modules_ also includes some super common patterns as included ModuleScripts. Check out the structure here: 30 | 31 |
└ ReplicatedStorage
32 |   └ Modules           ← This is the root ModuleScript
33 |     ├ Event
34 |     ├ Maid
35 |     ├ StateMachine
36 |     │ └ State
37 |     └ class
38 | 
39 | 40 | Each of these can be required by simply providing its name without a namespace. For example: 41 | 42 | ```lua 43 | local Event = require("Event") 44 | local State = require("StateMachine.State") 45 | ``` 46 | -------------------------------------------------------------------------------- /ldoc2mkdocs/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /ldoc2mkdocs/LDoc2MkDocs.py: -------------------------------------------------------------------------------- 1 | """ 2 | LDoc2MkDocs 3 | """ 4 | 5 | import os 6 | import json 7 | 8 | from pathlib import Path 9 | from jinja2 import Environment, FileSystemLoader 10 | 11 | from .filters import filters 12 | 13 | class LDoc2MkDocs: 14 | 15 | def __init__(self, doc_json_path, out_path, templates_dir_path, pretty=False): 16 | self.doc_json_path = doc_json_path 17 | self.out_path = out_path 18 | self.pretty = pretty 19 | self.pretty_path = out_path / 'docs.pretty.json' 20 | self.templates_dir_path = templates_dir_path 21 | 22 | self.env = Environment( 23 | loader=FileSystemLoader(str(self.templates_dir_path)), 24 | autoescape=False 25 | ) 26 | for filter_name in filters.keys(): 27 | self.env.filters[filter_name] = filters[filter_name] 28 | 29 | self.api_path = out_path / 'api' 30 | 31 | def convert(self): 32 | # Load JSON from file 33 | doc_data = self.read_doc_json() 34 | 35 | # Write a pretty version to file, for debugging 36 | if self.pretty: 37 | self.write_pretty_json(doc_data) 38 | 39 | # Build various globals 40 | self.env.globals['ldoc_raw'] = doc_data 41 | self.env.globals['ldoc'] = self.process_ldoc_entries(doc_data) 42 | 43 | # Create a path for API md files 44 | self.api_path.mkdir(exist_ok=True) 45 | 46 | # Convert entries into markdown 47 | for entry in doc_data: 48 | self.entry_to_md(entry, self.api_path / (entry['name'] + '.md')) 49 | 50 | def process_ldoc_entries(self, doc_data): 51 | ldocEntriesByName = dict() 52 | for entry in doc_data: 53 | ldocEntriesByName[entry['name']] = entry 54 | for item in entry['items']: 55 | item['refmod'] = entry['name'] 56 | item['refanchor'] = item['name'] 57 | ldocEntriesByName[item['name']] = item 58 | if not (':' in item['name'] or '.' in item['name']): 59 | fqName = entry['name'] + (':' if entry['type'] == 'classmod' and item['type'] not in ('staticfunction',) else '.') + item['name'] 60 | if fqName not in ldocEntriesByName: 61 | ldocEntriesByName[fqName] = item 62 | return ldocEntriesByName 63 | 64 | def read_doc_json(self): 65 | with open(str(self.doc_json_path), 'r') as f: 66 | return json.loads(f.read()) 67 | 68 | def write_pretty_json(self, data): 69 | with open(str(self.pretty_path), 'w') as f: 70 | f.write(json.dumps(data, indent='\t')) 71 | print('Wrote indented json to: ' + str(self.pretty_path)) 72 | 73 | def entry_to_md(self, entry, filepath): 74 | with open(str(filepath), 'w') as f: 75 | f.write(self.choose_entry_template(entry).render({ 76 | 'entry': entry 77 | })) 78 | 79 | def choose_entry_template(self, entry): 80 | return self.env.get_template('template.md') 81 | 82 | -------------------------------------------------------------------------------- /ldoc2mkdocs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ozzypig/Modules/e51d56366e7e3a107e9a478db10f7cb0aa2cb048/ldoc2mkdocs/__init__.py -------------------------------------------------------------------------------- /ldoc2mkdocs/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ldoc2mkdocs.py 3 | 4 | Usage: python ldoc2md.py DOCJSON OUTPATH 5 | 6 | Converts a json file containing a dump of LDoc's raw data output into Markdown ready for mkdoc. 7 | Uses Jinja2 templates to generate API doc pages from the 8 | 9 | """ 10 | 11 | from click import command, argument, option, format_filename, Path as click_Path 12 | from pathlib import Path 13 | 14 | from .LDoc2MkDocs import LDoc2MkDocs 15 | 16 | templates_dir_path = Path(__file__).parent.parent / 'doc-templates' 17 | 18 | @command(help='Convert a JSON file containing a dump of LDoc data into mkdocs-ready markdown files') 19 | @argument('doc_json_path', type=click_Path(exists=True, file_okay=True, dir_okay=False, readable=True)) 20 | @argument('out_path', type=click_Path(exists=True, file_okay=False, dir_okay=True, writable=True)) 21 | @option('-p', '--pretty', is_flag=True, help='Should a prettified copy of the json file be output as well?') 22 | def ldoc2mkdocs(doc_json_path, out_path, pretty): 23 | ldoc2mkdocs = LDoc2MkDocs( 24 | Path(doc_json_path), 25 | Path(out_path), 26 | templates_dir_path, 27 | pretty=pretty 28 | ) 29 | ldoc2mkdocs.convert() 30 | 31 | if __name__ == '__main__': 32 | ldoc2mkdocs() 33 | -------------------------------------------------------------------------------- /ldoc2mkdocs/filters.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from jinja2 import Markup, environmentfilter 4 | 5 | def uri_fragment(s): 6 | return s.replace('[^a-zA-Z_-/?]', '') 7 | 8 | def anchor_here(s): 9 | return Markup('
'.format(uri_fragment(s))) 10 | 11 | lua_types = ( 12 | 'nil', 'boolean', 'number', 'string', 'function', 'userdata', 'table', 13 | '...', 'float', 'double', 'integer', 'bool', 'array', 'dictionary' 14 | ) 15 | roblox_doc = 'https://developer.roblox.com/api-reference/' 16 | lua_manual = 'https://www.lua.org/manual/5.1/manual.html' 17 | 18 | @environmentfilter 19 | def xref_link(env, api_name): 20 | if api_name[0:4] == 'rbx:': 21 | api_name = api_name[4:] 22 | text = api_name 23 | if api_name[0:9] == 'datatype/': 24 | text = api_name[9:] 25 | elif api_name[0:5] == 'enum/': 26 | text = api_name[5:] 27 | elif api_name[0:6] == 'class/': 28 | text = api_name[6:] 29 | return (text, roblox_doc + api_name) 30 | elif api_name in lua_types: 31 | return (api_name, lua_manual) 32 | 33 | raw_data = env.globals['ldoc'].get(api_name) 34 | if raw_data: 35 | # Determine what md file we should cross-reference 36 | md_file = api_name + '.md' 37 | if 'refmod' in raw_data: 38 | md_file = env.globals['ldoc'][raw_data['refmod']]['name'] + '.md' 39 | target = md_file 40 | if 'refanchor' in raw_data: 41 | target += '#-' + uri_fragment(raw_data['refanchor']) 42 | return (api_name, target) 43 | else: 44 | raise Exception('unknown xref: ' + api_name) 45 | 46 | @environmentfilter 47 | def xref(env, text): 48 | text, target = xref_link(env, text) 49 | return '[{text}]({target})'.format( 50 | text=text, 51 | target=target 52 | ) 53 | 54 | @environmentfilter 55 | def xref_to(env, text, api_name): 56 | return '[{text}]({target})'.format( 57 | text=text, 58 | target=xref_link(env, api_name) 59 | ) 60 | 61 | # These regular expressions parse out LDoc-style cross references 62 | # For example @{Module} or @{Module|text} 63 | _re_xref_with_text = re.compile(r'@{(?P[^\}\|]+)\|(?P[^}]+)}') 64 | _re_xref = re.compile(r'@{(?P[^\}]+)}') 65 | 66 | """Jinja2 filter which transforms LDoc-style cross-references into Markdown links appropriate for mkdocs""" 67 | @environmentfilter 68 | def ldoc_filter(env, s): 69 | def repl(match): 70 | d = match.groupdict() 71 | api_name = d['api_name'] 72 | 73 | raw_data = env.globals['ldoc'].get(api_name) 74 | 75 | text = d.get('text', api_name) 76 | 77 | try: 78 | _, target = xref_link(env, api_name) 79 | return '[{text}]({target})'.format(text=text, target=target) 80 | except: 81 | return '{} (xref: \"{}\")'.format(text, api_name) 82 | 83 | s = _re_xref_with_text.sub(repl, s) 84 | s = _re_xref.sub(repl, s) 85 | return Markup(s) 86 | 87 | filters = { 88 | 'anchor_here': anchor_here, 89 | 'xref': xref, 90 | 'xref_link': xref_link, 91 | 'xref_to': xref_to, 92 | 'ldoc': ldoc_filter, 93 | } 94 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Modules Documentation 2 | repo_name: Ozzypig/Modules 3 | repo_url: https://github.com/Ozzypig/Modules 4 | 5 | nav: 6 | - Overview: index.md 7 | - Getting Started: getting-started.md 8 | - API: 9 | - Modules: 10 | - 'ModuleLoader': api/ModuleLoader.md 11 | - 'class': api/class.md 12 | - Classes: 13 | - 'Maid': api/Maid.md 14 | - 'Event': api/Event.md 15 | - 'StateMachine': api/StateMachine.md 16 | - 'State': api/State.md 17 | 18 | docs_dir: docs-build 19 | 20 | theme: 21 | name: material #readthedocs 22 | favicon: Modules-32.png 23 | logo: Modules-256.png 24 | icon: 25 | repo: fontawesome/brands/github 26 | palette: 27 | primary: deep orange 28 | accent: amber 29 | 30 | extra_css: 31 | - extra.css 32 | - https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/styles/default.min.css 33 | 34 | extra_javascript: 35 | - https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/highlight.min.js 36 | - javascripts/config.js 37 | 38 | edit_uri: "" 39 | 40 | markdown_extensions: 41 | - codehilite 42 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | mkdocs 2 | pymdown-extensions 3 | mkdocs-material 4 | click 5 | Jinja2 -------------------------------------------------------------------------------- /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox" 2 | 3 | [config] 4 | unused_variable = { allow_unused_self = true } 5 | multiple_statements = { one_line_if = "allow" } 6 | empty_if = { comments_count = true } 7 | -------------------------------------------------------------------------------- /src/Event.lua: -------------------------------------------------------------------------------- 1 | --[[-- Implementation of Roblox's event API using a @{rbx:class/BindableEvent|BindableEvent}. 2 | Event re-implements Roblox's event API (connect, fire, wait) by wrapping a 3 | @{rbx:class/BindableEvent|BindableEvent}. 4 | 5 | This Event implementation is based on the [Signal from Nevermore Engine by 6 | Quenty](https://github.com/Quenty/NevermoreEngine/blob/version2/Modules/Shared/Events/Signal.lua). 7 | 8 | #### Why? 9 | 10 | This implementation does not suffer from the restrictions normally introduced by using 11 | a BindableEvent. When firing a BindableEvent, the Roblox engine makes a copy of the values 12 | passed to @{rbx:function/BindableEvent/Fire|BindableEvent:Fire}. On the other hand, this class 13 | temporarily stores the values passed to @{Event:fire|fire}, fires the wrapped BindableEvent 14 | without arguments. The values are then retrieved and passed appropriately. 15 | 16 | This means that the _exact_ same values passed to @{Event:fire|fire} are 17 | sent to @{Event:connect|connected} handler functions and also returned by @{Event:wait|wait}, 18 | rather than copies. This includes tables with metatables, and other values that are normally 19 | not serializable by Roblox. 20 | 21 | #### Usage 22 | ```lua 23 | local zoneCapturedEvent = Event.new() 24 | 25 | -- Hook up a handler function using connect 26 | local function onZoneCaptured(teamName) 27 | print("The zone was captured by: " .. teamName) 28 | end 29 | zoneCapturedEvent:connect(onZoneCaptured) 30 | 31 | -- Or use wait, if you like that sort of thing 32 | local teamName 33 | while true do 34 | teamName = zoneCapturedEvent:wait() 35 | print("The zone was captured by: " .. teamName) 36 | end 37 | 38 | -- Trigger the event using fire 39 | zoneCapturedEvent:fire("Blue team") 40 | zoneCapturedEvent:fire("Red team") 41 | 42 | -- Remember to call cleanup then forget about the event when 43 | -- it is no longer needed! 44 | zoneCapturedEvent:cleanup() 45 | zoneCapturedEvent = nil 46 | ``` 47 | ]] 48 | -- @classmod Event 49 | 50 | local Event = {} 51 | Event.__index = Event 52 | Event.ClassName = "Event" 53 | 54 | --- Constructs a new Event. 55 | -- @staticfunction Event.new 56 | -- @constructor 57 | -- @treturn Event 58 | function Event.new() 59 | local self = setmetatable({}, Event) 60 | 61 | self._bindableEvent = Instance.new("BindableEvent") 62 | self._argData = nil 63 | self._argCount = nil -- Prevent edge case of :Fire("A", nil) --> "A" instead of "A", nil 64 | 65 | return self 66 | end 67 | 68 | --- Connect a new handler function to the event. Returns a connection object that can be disconnected. 69 | -- @tparam function handler Function handler called with arguments passed when `:Fire(...)` is called 70 | -- @treturn rbx:datatype/RBXScriptConnection Connection object that can be disconnected 71 | function Event:connect(handler) 72 | if not (type(handler) == "function") then 73 | error(("connect(%s)"):format(typeof(handler)), 2) 74 | end 75 | 76 | return self._bindableEvent.Event:Connect(function() 77 | handler(unpack(self._argData, 1, self._argCount)) 78 | end) 79 | end 80 | 81 | --- Wait for @{Event:fire|fire} to be called, then return the arguments it was given. 82 | -- @treturn ... Variable arguments from connection 83 | function Event:wait() 84 | self._bindableEvent.Event:Wait() 85 | assert(self._argData, "Missing arg data, likely due to :TweenSize/Position corrupting threadrefs.") 86 | return unpack(self._argData, 1, self._argCount) 87 | end 88 | 89 | --- Fire the event with the given arguments. All handlers will be invoked. Handlers follow 90 | -- Roblox Event conventions. 91 | -- @param ... Variable arguments to pass to handler 92 | function Event:fire(...) 93 | self._argData = {...} 94 | self._argCount = select("#", ...) 95 | self._bindableEvent:Fire() 96 | self._argData = nil 97 | self._argCount = nil 98 | end 99 | 100 | --- Disconnects all connected events to the Event. Voids the Event as unusable. 101 | function Event:cleanup() 102 | if self._bindableEvent then 103 | self._bindableEvent:Destroy() 104 | self._bindableEvent = nil 105 | end 106 | 107 | self._argData = nil 108 | self._argCount = nil 109 | end 110 | 111 | -- Aliases 112 | Event.Wait = Event.wait 113 | Event.Fire = Event.fire 114 | Event.Connect = Event.connect 115 | Event.Destroy = Event.cleanup 116 | 117 | return Event -------------------------------------------------------------------------------- /src/Maid.lua: -------------------------------------------------------------------------------- 1 | --[[- Utility object for cleaning up, destroying and otherwising releasing resources. 2 | A **Maid** is provided tasks which it will handle when it is told to @{Maid:cleanup|cleanup}. 3 | A task may be a function, connection, Roblox Instance, or table with a `cleanup` function. 4 | Connections are always disconnected before other tasks. 5 | 6 | This Maid implementation is based on the 7 | [Maid from Nevermore Engine by Quenty](https://github.com/Quenty/NevermoreEngine/blob/version2/Modules/Shared/Events/Maid.lua). 8 | 9 | #### Usage 10 | 11 | ```lua 12 | local maid = Maid.new() 13 | 14 | -- Something you might need to clean up: 15 | local part = workspace.SomePart 16 | 17 | -- Add tasks to the maid. A task can be... 18 | maid:addTask(part) -- a Roblox instance 19 | maid:addTask(part.Touched:Connect(...)) -- a connection 20 | maid:addTask(function() ... end) -- a function 21 | maid:addTask(Event.new()) -- something with a cleanup function 22 | 23 | -- You can add tasks by id: 24 | maid["somePart"] = Instance.new("Part") -- "somePart" is a task id 25 | maid["somePart"] = Instance.new("Part") -- the first part gets cleaned up 26 | -- because "somePart" got overwritten! 27 | 28 | -- Instruct the maid to perform all tasks: 29 | maid:cleanup() 30 | ``` 31 | ]] 32 | -- @classmod Maid 33 | 34 | local Maid = {} 35 | --Maid.__index = Maid 36 | 37 | --- Constructs a new Maid. 38 | -- @constructor 39 | -- @treturn Maid 40 | function Maid.new() 41 | local self = setmetatable({ 42 | 43 | --- Stores this maid's tasks 44 | -- @field Maid.tasks 45 | -- @treturn table 46 | tasks = {}; 47 | 48 | }, Maid) 49 | return self 50 | end 51 | 52 | function Maid:__index(index) 53 | if Maid[index] then 54 | return Maid[index] 55 | else 56 | return self.tasks[index] 57 | end 58 | end 59 | 60 | function Maid:__newindex(index, newTask) 61 | if type(Maid[index]) ~= "nil" then 62 | error(("\"%s\" is reserved"):format(tostring(index)), 2) 63 | end 64 | local oldTask = self.tasks[index] 65 | self.tasks[index] = newTask 66 | if oldTask then self:performTask(oldTask) end 67 | end 68 | 69 | --- Executes the given task 70 | function Maid:performTask(task) 71 | local ty = typeof(task) 72 | if ty == "function" then 73 | task() 74 | elseif ty == "Instance" then 75 | task:Destroy() 76 | elseif ty == "RBXScriptConnection" then 77 | task:disconnect() 78 | elseif task.cleanup then 79 | task:cleanup() 80 | else 81 | error(("unknown task type \"%s\""):format(ty)) 82 | end 83 | end 84 | 85 | --- Give this maid a task, and returns its id. 86 | -- @treturn number the task id 87 | function Maid:addTask(task) 88 | assert(task, "Task expected") 89 | local taskId = #self.tasks + 1 90 | self[taskId] = task 91 | return taskId 92 | end 93 | Maid.giveTask = Maid.addTask 94 | 95 | --- Cause the maid to do all of its tasks then forget about them 96 | function Maid:cleanup() 97 | local tasks = self.tasks 98 | 99 | -- Disconnect first 100 | for index, task in pairs(tasks) do 101 | if typeof(task) == "RBXScriptConnection" then 102 | tasks[index] = nil 103 | task:disconnect() 104 | end 105 | end 106 | 107 | -- Clear tasks table (don't use generic for here) 108 | local index, task = next(tasks) 109 | while type(task) ~= "nil" do 110 | self:performTask(task) 111 | tasks[index] = nil 112 | index, task = next(tasks) 113 | end 114 | end 115 | 116 | return Maid -------------------------------------------------------------------------------- /src/StateMachine/State.lua: -------------------------------------------------------------------------------- 1 | --[[-- A uniquely identified state of a @{StateMachine}. 2 | A **State** is an object with a unique @{State.id|id} that represents one possible state of a parent @{StateMachine}. 3 | A state can be associated with only one machine (its parent), and its id must be unique within its machine. 4 | When the parent machine has entered a state, that state is considered @{State:isActive|active} until it 5 | @{State:leave|leaves} the state. 6 | 7 | States can be @{State.new|constructed}, @{StateMachine:addState|added} and @{State:cleanup|cleaned up} manually, 8 | or they may be created through @{StateMachine:newState}. For manually-constructed states, it's helpful to 9 | @{Maid:addTask|add} the state as a task to the machine's @{StateMachine.maid|maid} so it is cleaned up 10 | 11 | #### Event-based Usage 12 | 13 | ```lua 14 | local sm = StateMachine.new() 15 | 16 | -- Create some states 17 | local stateGame = sm:newState("game") 18 | local stateShop = sm:newState("shop") 19 | stateShop.onEnter:connect(function () 20 | print("Welcome to the shop!") 21 | end) 22 | stateShop.onLeave:connect(function () 23 | print("Come back soon.") 24 | end) 25 | 26 | -- Make some transitions 27 | sm:transition("game") 28 | sm:transition("shop") --> Welcome to the shop! 29 | sm:transition("game") --> Come back soon. 30 | ``` 31 | 32 | #### Subclass Usage 33 | 34 | ```lua 35 | local CounterState = setmetatable({}, State) 36 | CounterState.__index = CounterState 37 | 38 | function CounterState.new(...) 39 | local self = setmetatable(State.new(...), CounterState) 40 | 41 | -- Tracks number of times it has been transitioned to 42 | self.transitionCount = 0 43 | 44 | return self 45 | end 46 | 47 | function CounterState:enter(...) 48 | State.enter(self, ...) -- call super 49 | self.transitionCount = self.transitionCount + 1 50 | end 51 | ``` 52 | 53 | ##### Usage 54 | 55 | ```lua 56 | local sm = StateMachine.new() 57 | sm.StateClass = CounterState -- StateMachine:newState now uses CounterState 58 | 59 | local firstState = sm:newState("first") 60 | sm:newState("second") 61 | sm:newState("third") 62 | 63 | sm:transition("first") 64 | sm:transition("second") 65 | sm:transition("first") 66 | 67 | print("Transitions: " .. firstState.transitionCount) --> Transitions: 2 68 | ``` 69 | 70 | ]] 71 | -- @classmod State 72 | 73 | local Maid = require(script.Parent.Parent:WaitForChild("Maid")) 74 | local Event = require(script.Parent.Parent:WaitForChild("Event")) 75 | 76 | local State = {} 77 | State.__index = State 78 | 79 | --- Construct a new state given the @{StateMachine} it should belong to, its unique id within this machine, 80 | -- and optionally a function to immediately connect to the @{State.onEnter|onEnter} event. 81 | -- This is called by @{StateMachine:newState}, automatically providing the machine to this constructor. 82 | -- @tparam StateMachine machine The `StateMachine` to which the new state should belong. 83 | -- @tparam string id An identifier for the new state, must be unique within the parent `StateMachine`. 84 | -- @tparam[opt] function onEnterCallback A `function` to immediately connect to @{State.onEnter|onEnter} 85 | -- @constructor 86 | function State.new(machine, id, onEnterCallback) 87 | if type(id) ~= "string" then error("State.new expects string id", 3) end 88 | local self = setmetatable({ 89 | --- The parent @{StateMachine} which owns this state and can @{StateMachine:transition|transition} to it. 90 | -- @treturn StateMachine 91 | machine = machine; 92 | 93 | --- The unique string that identifies this state in its machine 94 | -- @treturn string 95 | id = id; 96 | 97 | --- A @{Maid} invoked upon @{StateMachine:cleanup|cleanup} 98 | -- @treturn Maid 99 | maid = Maid.new(); 100 | 101 | --- Fires when the parent @{State.machine|machine} @{StateMachine:transition|transitions} into this state. 102 | -- @event onEnter 103 | -- @tparam State prevState The @{State} which the parent {@State.machine|machine} had left (if any) 104 | onEnter = Event.new(); 105 | 106 | --- Fires when parent @{State.machine|machine} @{StateMachine:transition|transitions} out of this state. 107 | -- @event onLeave 108 | -- @tparam State nextState The @{State} which the parent {@State.machine|machine} will enter (if any) 109 | onLeave = Event.new(); 110 | 111 | --- Tracks the "Active" States of any @{StateMachine:newSubmachine|sub-machines} for this state, 112 | -- transitioning to them when this state is @{State.onEnter|onEnter}. 113 | -- @private 114 | submachineActiveStates = {}; 115 | 116 | --- Tracks the "Inactive" States of any @{StateMachine:newSubmachine|sub-machines} for this state, 117 | -- transitioning to them when this state is @{State.onLeave|onLeave}. 118 | -- @private 119 | submachineInactiveStates = {}; 120 | }, State) 121 | self.maid:addTask(self.onEnter) 122 | self.maid:addTask(self.onLeave) 123 | 124 | if type(onEnterCallback) == "function" then 125 | self.maid:addTask(self.onEnter:connect(onEnterCallback)) 126 | elseif type(onEnterCallback) == "nil" then 127 | -- that's ok 128 | else 129 | error("State.new() was given non-function onEnterCallback (" .. type(onEnterCallback) .. ", " .. tostring(onEnterCallback) .. ")") 130 | end 131 | 132 | return self 133 | end 134 | 135 | --- Returns a string with this state's @{State.id|id}. 136 | function State:__tostring() 137 | return (""):format(self.id) 138 | end 139 | 140 | --- Clean up resources used by this state. 141 | -- Careful, this function doesn't remove this state from the parent @{State.machine|machine}! 142 | function State:cleanup() 143 | self.submachineInactiveStates = nil 144 | self.submachineActiveStates = nil 145 | self.machine = nil 146 | if self.maid then 147 | self.maid:cleanup() 148 | self.maid = nil 149 | end 150 | end 151 | 152 | --- Called when the parent @{State.machine|machine} enters this state, firing 153 | -- the @{State.onEnter|onEnter} event with all given arguments. 154 | -- If this state has any @{StateMachine:newSubmachine|sub-machines}, 155 | -- they will transition to the "Active" state. 156 | function State:enter(...) 157 | self.onEnter:fire(...) 158 | for submachine, activeState in pairs(self.submachineActiveStates) do 159 | if not submachine:isInState(activeState) then 160 | submachine:transition(activeState) 161 | end 162 | end 163 | end 164 | 165 | --- Called when the parent @{State.machine|machine} leaves this state, firing 166 | -- the @{State.onLeave|onLeave} event with all given arguments. 167 | -- If this state has any @{StateMachine:newSubmachine|sub-machines}, 168 | -- they will transition to the "Inactive" state. 169 | function State:leave(...) 170 | self.onLeave:fire(...) 171 | for submachine, inactiveState in pairs(self.submachineInactiveStates) do 172 | if not submachine:isInState(inactiveState) then 173 | submachine:transition(inactiveState) 174 | end 175 | end 176 | end 177 | 178 | --- Returns whether the parent @{State.machine|machine} is @{StateMachine:isInState|currently in} this state. 179 | -- @treturn boolean 180 | function State:isActive() 181 | return self.machine:isInState(self) 182 | end 183 | 184 | --- Orders the parent @{State.machine|machine} to @{StateMachine:transition|transition} to this state. 185 | -- @return The result of the @{StateMachine:transition|transition}. 186 | function State:transition() 187 | return self.machine:transition(self) 188 | end 189 | 190 | --- Helper function for @{StateMachine:newSubmachine} 191 | -- @private 192 | function State:addSubmachine(submachine, inactiveState, activeState) 193 | self.submachineInactiveStates[submachine] = inactiveState 194 | self.submachineActiveStates[submachine] = activeState 195 | return inactiveState, activeState 196 | end 197 | 198 | return State 199 | -------------------------------------------------------------------------------- /src/StateMachine/init.lua: -------------------------------------------------------------------------------- 1 | --[[-- A mechanism that transitions between @{State|states}. 2 | 3 | A **StateMachine** (or machine) is a mechanism that @{StateMachine:transition|transitions} between several 4 | @{State|states} that have been @{StateMachine:addState|added}, usually through @{StateMachine:newState|newState}. 5 | When a transition is performed, the previous state is @{State:leave|left}, the 6 | @{StateMachine.onTransition|onTransition} event fires, and the new state is @{State:enter|entered}. 7 | Machines do not begin in any state in particular, but rather a nil state. 8 | 9 | #### Event-based Usage 10 | 11 | ```lua 12 | local sm = StateMachine.new() 13 | 14 | -- Create some states 15 | local stateGame = sm:newState("game") 16 | local stateShop = sm:newState("shop") 17 | stateShop.onEnter:connect(function () 18 | print("Welcome to the shop!") 19 | end) 20 | stateShop.onLeave:connect(function () 21 | print("Come back soon.") 22 | end) 23 | 24 | -- Make some transitions 25 | sm:transition("game") 26 | sm:transition("shop") --> Welcome to the shop! 27 | sm:transition("game") --> Come back soon. 28 | ``` 29 | 30 | #### Subclass Usage 31 | 32 | ```lua 33 | local MyMachine = setmetatable({}, StateMachine) 34 | MyMachine.__index = MyMachine 35 | 36 | function MyMachine.new() 37 | local self = setmetatable(StateMachine.new(), MyMachine) 38 | 39 | -- States 40 | self.defaultState = self:newState("default") 41 | self.spamState = self:newState("spam") 42 | self.eggsState = self:newState("eggs") 43 | self.maid:addTask(self.spamState.onEnter:connect(function () 44 | self:onSpamStateEntered() 45 | end)) 46 | 47 | -- Transition counter 48 | self.transitionCounter = 0 49 | 50 | -- Default state 51 | self:transition(self.defaultState) 52 | 53 | return self 54 | end 55 | 56 | function MyMachine:transition(...) 57 | StateMachine.transition(self, ...) -- call super 58 | self.transitionCounter = self.transitionCounter + 1 59 | print("Transitions: " .. self.transitionCounter) 60 | end 61 | 62 | function MyMachine:onSpamStateEntered() 63 | print("Spam!") 64 | end 65 | ``` 66 | 67 | ##### Usage 68 | 69 | ```lua 70 | local myMachine = MyMachine.new() 71 | myMachine:transition("spam") 72 | myMachine:transition("eggs") 73 | ``` 74 | 75 | #### Sub-machines 76 | 77 | Certain @{State|states} may control their own StateMachine (a sub-StateMachine or submachine). When a state with a sub-machine is entered, 78 | the submachine enters the "Active" state. Upon leaving, it enters the "Inactive" state. 79 | ]] 80 | --@classmod StateMachine 81 | 82 | local Maid = require(script.Parent:WaitForChild("Maid")) 83 | local Event = require(script.Parent:WaitForChild("Event")) 84 | 85 | local State = require(script:WaitForChild("State")) 86 | 87 | local StateMachine = {} 88 | StateMachine.__index = StateMachine 89 | 90 | --- The @{State} class used when creating a @{StateMachine:newState|new state} via this machine. 91 | -- @field StateMachine.StateClass 92 | StateMachine.StateClass = State 93 | 94 | --- The @{StateMachine} class used when creating a @{StateMachine:newSubmachine|new submachine} for a state via this machine. 95 | -- @field StateMachine.SubStateMachineClass 96 | StateMachine.SubStateMachineClass = StateMachine 97 | 98 | --- Construct a new StateMachine. 99 | -- The new machine has no states @{StateMachine:addState|added} to it, and is not in any state to begin with. 100 | -- @constructor 101 | -- @treturn StateMachine The new state machine with no @{State|states}. 102 | function StateMachine.new() 103 | local self = setmetatable({ 104 | --- Refers to the current @{State} the machine is in, if any. Use @{StateMachine:isInState|isInState} 105 | -- to check the current state by object or id. 106 | state = nil; 107 | 108 | --- Dictionary of @{State|states} by @{State.id|id} that have been @{StateMachine:addState|added} 109 | -- @treturn dictionary 110 | states = {}; 111 | 112 | --- A @{Maid} invoked upon @{StateMachine:cleanup|cleanup} 113 | -- Cleans up @{StateMachine.onTransition|onTransition} and states constructed 114 | -- through @{StateMachine:newState|newState}. 115 | -- @treturn Maid 116 | maid = Maid.new(); 117 | 118 | --- Fires when the machine @{StateMachine:transition|transitions} between states. 119 | -- @event onTransition 120 | -- @tparam State oldState The state which the machine is leaving, if any 121 | -- @tparam State newState The state whihc the machine is entering, if any 122 | onTransition = Event.new(); 123 | 124 | --- A flag which enables transition @{StateMachine:print|printing} for this machine. 125 | -- @treturn boolean 126 | debugMode = false; 127 | }, StateMachine) 128 | self.maid:addTask(self.onTransition) 129 | return self 130 | end 131 | 132 | --- Returns a string with the current @{State} this machine is in (if any), calling @{State:__tostring}. 133 | function StateMachine:__tostring() 134 | return (""):format(self.state and tostring(self.state) or "") 135 | end 136 | 137 | --- Wraps the default `print` function; does nothing if @{StateMachine.debugMode|debugMode} is false. 138 | function StateMachine:print(...) 139 | if self.debugMode then 140 | print(...) 141 | end 142 | end 143 | 144 | --- Clean up resources used by this machine by calling @{Maid:cleanup|cleanup} on 145 | -- this machine's @{StateMachine.maid|maid}. 146 | -- States created with @{StateMachine:newState|newState} are cleaned up as well. 147 | function StateMachine:cleanup() 148 | if self.maid then 149 | self.maid:cleanup() 150 | self.maid = nil 151 | end 152 | self.states = nil 153 | end 154 | 155 | --- Add a @{State|State} to this machine's @{StateMachine.states|states}. 156 | -- @tparam State state A @{State|State} object 157 | -- @return The @{State|state} that was added. 158 | function StateMachine:addState(state) 159 | --if not state or getmetatable(state) ~= State then error("StateMachine:addState() expects state", 2) end 160 | self.states[state.id] = state 161 | return state 162 | end 163 | 164 | --- @{State.new|Construct} and @{StateMachine:addState|add} a new @{State|state} of type 165 | -- @{StateMachine.StateClass|StateClass} (by default, @{State}) for this machine. 166 | -- The state is @{Maid:addTask|added} as a @{StateMachine.maid|maid} task. 167 | -- @return The newly constructed @{State|state} . 168 | function StateMachine:newState(...) 169 | local state = self.StateClass.new(self, ...) 170 | self.maid:addTask(state) 171 | return self:addState(state) 172 | end 173 | 174 | --- Determines whether this machine has a @{State|state} with the given id. 175 | -- @tparam string id The id of the state to check. 176 | -- @treturn boolean Whether the machine has a @{State|state} with the given id. 177 | function StateMachine:hasState(id) 178 | return self.states[id] ~= nil 179 | end 180 | 181 | --- Get a @{State|state} by id that was previously @{StateMachine:addState|added} to this machine. 182 | -- @tparam string id The id of the state. 183 | -- @treturn State The state, or nil if no state was added with the given id. 184 | function StateMachine:getState(id) 185 | return self.states[id] 186 | end 187 | 188 | --- If given an id of a state, return the state with that id (or produce an error no such state exists). 189 | -- Otherwise, returns the given state. 190 | -- @private 191 | function StateMachine:_stateArg(stateOrId) 192 | local state = stateOrId 193 | if type(stateOrId) == "string" then 194 | state = self:hasState(stateOrId) 195 | and self:getState(stateOrId) 196 | or error("Unknown state id: " .. tostring(stateOrId)) 197 | --else 198 | -- TODO: verify stateOrId is in fact a State 199 | end 200 | return state 201 | end 202 | 203 | --- Returns whether the machine is currently in the given state or state with given id 204 | -- @tparam ?State|string state The state or id of the state to check 205 | -- @treturn boolean 206 | function StateMachine:isInState(state) 207 | assert(type(state) ~= "nil", "Must provide non-nil state") 208 | return self.state == self:_stateArg(state) 209 | end 210 | 211 | --- Transition the machine to another state, firing all involved events in the process. 212 | -- This method will @{StateMachine:print|print} transitions before making them if the machine 213 | -- has @{StateMachine.debugMode|debugMode} set. 214 | -- Events are fired in the following order: @{State.onLeave|old state onLeave}, 215 | -- @{StateMachine.onTransition|machine onTransition}, then finally @{State.onEnter|new state onEnter}. 216 | -- @tparam ?State|string stateNew The state to which the machine should transition, or its `id`. 217 | function StateMachine:transition(stateNew) 218 | if type(stateNew) == "string" then stateNew = self:hasState(stateNew) and self:getState(stateNew) or error("Unknown state id: " .. tostring(stateNew), 2) end 219 | if type(stateNew) == "nil" then error("StateMachine:transition() requires state", 2) end 220 | --if getmetatable(stateNew) ~= State then error("StateMachine:transition() expects state", 2) end 221 | 222 | local stateOld = self.state 223 | self:print(("%s -> %s"):format(stateOld and stateOld.id or "(none)", stateNew and stateNew.id or "(none)")) 224 | 225 | self.state = stateNew 226 | if stateOld then stateOld:leave(stateNew) end 227 | self.onTransition:fire(stateOld, stateNew) 228 | if stateNew then stateNew:enter(stateOld) end 229 | end 230 | 231 | --[[-- Create a StateMachine of type @{StateMachine.SubStateMachineClass|SubStateMachineClass}, given a @{State|state}. 232 | Two @{StateMachine:newState|new states} are created on the sub-StateMachine with ids "Active" and "Inactive": 233 | 234 | * When the parent machine @{State.onEnter|enters} the given state, the sub-StateMachine transitions to "Active". 235 | * When the parent machine @{State.onLeave|leaves} the given state, the sub-StateMachine transitions to "Inactive". 236 | 237 | The sub-StateMachine is @{Maid:addTask|added} as a task to this machine's @{StateMachine.maid|maid}. 238 | ]] 239 | -- @tparam State state The @{State} to implement the new sub-machine. 240 | function StateMachine:newSubmachine(state) 241 | local submachine = self.SubStateMachineClass.new() 242 | self.maid:addTask(submachine) 243 | 244 | local inactiveState = submachine:newState("Inactive") 245 | local activeState = submachine:newState("Active") 246 | 247 | -- Initial transition 248 | if self:isInState(state) then 249 | submachine:transition(activeState) 250 | else 251 | submachine:transition(inactiveState) 252 | end 253 | 254 | return submachine, state:addSubmachine(submachine, inactiveState, activeState) 255 | end 256 | 257 | return StateMachine 258 | -------------------------------------------------------------------------------- /src/class.lua: -------------------------------------------------------------------------------- 1 | --- @module class 2 | -- Utility for working with idiomatic Lua object-oriented patterns 3 | 4 | local class = {} 5 | 6 | --- Given an `object`, return its class 7 | function class.classOf(object) 8 | return getmetatable(object) 9 | end 10 | 11 | --- Given an `object` and a `class`, return if that object is an instance of another class/superclass 12 | -- Equivalent to @{class.extends}(@{class.classOf}(object), `cls`) 13 | function class.instanceOf(object, cls) 14 | return class.extends(class.classOf(object), cls) 15 | end 16 | 17 | --- Get the superclass of a class 18 | function class.getSuperclass(theClass) 19 | local meta = getmetatable(theClass) 20 | return meta and meta.__index 21 | end 22 | 23 | --- Check if one class extends another class 24 | function class.extends(subclass, superclass) 25 | assert(type(subclass) == "table") 26 | assert(type(superclass) == "table") 27 | local c = subclass 28 | repeat 29 | if c == superclass then return true end 30 | c = class.getSuperclass(c) 31 | until not c 32 | return false 33 | end 34 | 35 | return class 36 | -------------------------------------------------------------------------------- /src/init.lua: -------------------------------------------------------------------------------- 1 | --- The main ModuleLoader, designed to replace the built-in require function. 2 | -- ModuleLoader is the main object returned by the "Modules" ModuleScript. It is designed to 3 | -- replace the built-in `require` function, retaining all its normal behaviors while also adding 4 | -- more: 5 | -- 6 | -- * Call with a string, eg `require("Namespace:ModuleName")`, to require a Module in a Namespace 7 | -- * Call `require.server` to skip requires if on the client (get nil instead) 8 | -- * Call `require.client` to skip requires if on the server (get nil instead) 9 | -- 10 | -- @usage local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules")) 11 | -- @module ModuleLoader 12 | 13 | local RunService = game:GetService("RunService") 14 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 15 | local ServerScriptService = game:GetService("ServerScriptService") 16 | 17 | local isServer = RunService:IsServer() 18 | local isClient = RunService:IsClient() 19 | 20 | local ModuleLoader = {} 21 | setmetatable(ModuleLoader, ModuleLoader) 22 | 23 | ModuleLoader.NAME_SPLIT_PATTERN = "%." 24 | ModuleLoader.VERSION = require(script.version) 25 | ModuleLoader.DEBUG_MODE = (function () 26 | return script:FindFirstChild("DebugEnablede") 27 | or (script:FindFirstChild("Debug") 28 | and script.Debug:IsA("BoolValue") 29 | and script.Debug.Value) 30 | end)() 31 | 32 | --- Starting with `parent`, call FindFirstChild using each name in the `names` array 33 | -- until one is found 34 | --@return Instance 35 | local function getObject(parent, names) 36 | assert(typeof(parent) == "Instance") 37 | assert(type(names) == "table") 38 | local object = parent 39 | for i = 1, #names do 40 | if not object then return end 41 | object = object:FindFirstChild(names[i]) 42 | end 43 | return object 44 | end 45 | 46 | --- Split the given string by repeatedly calling find on it using the given separator 47 | local function split(str, sep) 48 | local strs = {} 49 | local s, e 50 | while true do 51 | s, e = str:find(sep) 52 | if s then 53 | table.insert(strs, str:sub(1, s - 1)) 54 | str = str:sub(e + 1) 55 | else 56 | break 57 | end 58 | end 59 | table.insert(strs, str) 60 | return strs 61 | end 62 | 63 | --- Given a string like "Module.Submodule.With%.Period.Whatever", split it on . and return a table of strings 64 | local function splitNames(str) 65 | local t = split(str, ModuleLoader.NAME_SPLIT_PATTERN) 66 | local i = 1 67 | while i <= #t do 68 | if t[i]:sub(t[i]:len()) == "%" then 69 | -- Trim the trailing % 70 | t[i] = t[i]:sub(1, t[i]:len() - 1) 71 | -- Concatenate with the next string (if there is one) 72 | if i < #t then 73 | t[i] = t[i] .. "." .. table.remove(t, i + 1) 74 | end 75 | else 76 | -- Move to the next 77 | i = i + 1 78 | end 79 | end 80 | return t 81 | end 82 | 83 | --- Prints only if `DEBUG_MODE` is true. 84 | -- @function ModuleLoader:_print 85 | -- @local 86 | function ModuleLoader:_print(...) 87 | if ModuleLoader.DEBUG_MODE then 88 | print(...) 89 | end 90 | end 91 | 92 | --- Finds a module given its fully-qualified name 93 | -- @private 94 | function ModuleLoader:_findModule(fqName) 95 | assert(type(fqName) == "string") 96 | 97 | -- Determine the namespace and module name - the default namespace is "Modules" 98 | local namespace = "Modules" 99 | local moduleName = fqName 100 | 101 | -- A colon indicates that a namespace is specified - split it if so 102 | local s,e = fqName:find("[^%%]:") 103 | if s then 104 | namespace = fqName:sub(1, s) 105 | moduleName = fqName:sub(e + 1) 106 | end 107 | 108 | local namespaceNames = splitNames(namespace) 109 | local moduleNames = splitNames(moduleName) 110 | 111 | -- Try to find what we're looking for on the client 112 | local namespaceClient = namespace == script.Name and script 113 | or getObject(ReplicatedStorage, namespaceNames) 114 | local moduleClient = getObject(namespaceClient, moduleNames) 115 | 116 | if not isServer then 117 | if not namespaceClient then 118 | error(("Could not find client namespace: %s"):format(namespace), 3) 119 | end 120 | if not moduleClient then 121 | error(("Could not find client module: %s"):format(fqName), 3) 122 | end 123 | return moduleClient 124 | elseif moduleClient then 125 | return moduleClient 126 | end 127 | 128 | -- Try to find what we're looking for on the server 129 | local moduleServer 130 | local namespaceServer = namespace == script.Name and script 131 | or getObject(ServerScriptService, namespaceNames) 132 | if not namespaceServer then 133 | error(("Could not find namespace: %s"):format(namespace), 3) 134 | end 135 | moduleServer = getObject(namespaceServer, moduleNames) 136 | if not moduleServer then 137 | error(("Could not find module: %s"):format(fqName), 3) 138 | end 139 | return moduleServer 140 | end 141 | 142 | ModuleLoader.SAFE_REQUIRE_WARN_TIMEOUT = .5 143 | 144 | --- A wrapper for require which causes a warning if the module is taking too long to load. 145 | -- @private 146 | function ModuleLoader:_safe_require(mod, requiring_mod) 147 | local startTime = os.clock() 148 | local conn 149 | conn = RunService.Stepped:connect(function () 150 | if os.clock() >= startTime + ModuleLoader.SAFE_REQUIRE_WARN_TIMEOUT then 151 | warn(("%s -> %s is taking too long"):format(tostring(requiring_mod), tostring(mod))) 152 | if conn then 153 | conn:disconnect() 154 | conn = nil 155 | end 156 | end 157 | end) 158 | local retval 159 | local success, err = pcall(function () 160 | retval = require(mod) 161 | end) 162 | if not success then 163 | if type(retval) == "nil" and err:find("exactly one value") then 164 | error("Module did not return exactly one value: " .. mod:GetFullName(), 3) 165 | else 166 | error("Module " .. mod:GetFullName() .. " experienced an error while loading: " .. err, 3) 167 | end 168 | --else 169 | -- We're good to go 170 | end 171 | if conn then 172 | conn:disconnect() 173 | conn = nil 174 | end 175 | return retval 176 | end 177 | 178 | --- When called by a function that replaces `require`, this function returns the LuaSourceContainer 179 | -- which called `require`. 180 | --@private 181 | function ModuleLoader:_getRequiringScript() 182 | return getfenv(3).script 183 | end 184 | 185 | --- Basic memoization pattern 186 | -- @private 187 | ModuleLoader._cache = {} 188 | 189 | --- Main logic of all flavors of require calls. 190 | -- @private 191 | function ModuleLoader:_require(object, requiring_script) 192 | if not requiring_script then 193 | requiring_script = ModuleLoader:_getRequiringScript() 194 | end 195 | self:_print(("%s -> %s%s"):format( 196 | tostring(requiring_script), 197 | tostring(object), 198 | (ModuleLoader._cache[object] ~= nil and " (cached)" or "") 199 | )) 200 | 201 | if ModuleLoader._cache[object] then 202 | return ModuleLoader._cache[object] 203 | end 204 | 205 | local object_type = typeof(object) 206 | local retval 207 | if object_type == "number" then 208 | -- Use plain require instead of _safe_require, as asset IDs need to load 209 | retval = require(object) 210 | elseif object_type == "Instance" then 211 | if object:IsA("ModuleScript") then 212 | retval = ModuleLoader:_safe_require(object, requiring_script) 213 | else 214 | error("Non-ModuleScript passed to require: " .. object:GetFullName(), 2) 215 | end 216 | elseif object_type == "string" then 217 | local moduleScript = self:_findModule(object) 218 | assert(moduleScript, ("Could not find module: %s"):format(tostring(object))) 219 | retval = ModuleLoader:_safe_require(moduleScript, requiring_script) 220 | elseif object_type == "nil" then 221 | error("require expects ModuleScript, asset id or string", 2) 222 | else 223 | error("Unknown type passed to require: " .. object_type, 2) 224 | end 225 | 226 | assert(retval, "No retval from require") 227 | ModuleLoader._cache[object] = retval 228 | return retval 229 | end 230 | 231 | --- The main `require` overload. 232 | -- @param object An object normally passed to require OR a string 233 | function ModuleLoader.require(object) 234 | local script = ModuleLoader:_getRequiringScript() 235 | return ModuleLoader:_require(object, script) 236 | end 237 | 238 | --- Alias for calling @{ModuleLoader.require}, useful when using ModuleLoader as a `require` replacement. 239 | function ModuleLoader:__call(object) 240 | local script = self:_getRequiringScript() 241 | return self:_require(object, script) 242 | end 243 | 244 | --- Like @{ModuleLoader.require|require}, but returns nil if not ran on the server. 245 | function ModuleLoader.server(object) 246 | local requiringScript = ModuleLoader:_getRequiringScript() 247 | if isServer then 248 | return ModuleLoader:_require(object, requiringScript) 249 | else 250 | return nil 251 | end 252 | end 253 | 254 | --- Like @{ModuleLoader.require|require}, but returns nil if not ran on a client. 255 | function ModuleLoader.client(object) 256 | local requiringScript = ModuleLoader:_getRequiringScript() 257 | if isClient then 258 | return ModuleLoader:_require(object, requiringScript) 259 | else 260 | return nil 261 | end 262 | end 263 | 264 | --- Copies "Replicated" folders in ServerScriptService modules to ReplicatedStorage, renaming them. 265 | -- Also signals that all libraries have been replicated. 266 | -- @private 267 | function ModuleLoader._replicateLibraries() 268 | -- Search for modules with "-Replicated" at the end and replicate them 269 | -- Alternatively, child folder named "Replicated" is moved and renamed 270 | for _, child in pairs(ServerScriptService:GetChildren()) do 271 | if child:IsA("Folder") then 272 | if child.Name:find("%-Replicated$") then 273 | child.Name = child.Name:sub(1, child.Name:len() - 11) 274 | child.Parent = ReplicatedStorage 275 | elseif child:FindFirstChild("Replicated") then 276 | local rep = child.Replicated 277 | rep.Name = child.Name 278 | rep.Parent = ReplicatedStorage 279 | end 280 | end 281 | end 282 | ModuleLoader._signalAllLibrariesReplicated() 283 | end 284 | 285 | ModuleLoader.ALL_LIBRARIES_REPLICATED = "AllLibrariesReplicated" 286 | --- Signals to clients that all libraries have been replicated by creating a foldr in ReplicatedStorage. 287 | -- @private 288 | function ModuleLoader._signalAllLibrariesReplicated() 289 | local allLibrariesReplicatedTag = Instance.new("Folder") 290 | allLibrariesReplicatedTag.Name = ModuleLoader.ALL_LIBRARIES_REPLICATED 291 | allLibrariesReplicatedTag.Parent = ReplicatedStorage 292 | end 293 | 294 | --- Waits for the server to signal that all libraries have been replicated. 295 | -- @private 296 | function ModuleLoader._waitForLibrariesToReplicate() 297 | ReplicatedStorage:WaitForChild(ModuleLoader.ALL_LIBRARIES_REPLICATED) 298 | end 299 | 300 | --- Returns the version of the ModuleLoader being used 301 | -- @function ModuleLoader:__tostring 302 | function ModuleLoader:__tostring() 303 | return (""):format(self.VERSION) 304 | end 305 | 306 | -- Server should replicate 307 | if isServer then ModuleLoader._replicateLibraries() end 308 | -- Client should wait for modules to replicate 309 | if isClient then ModuleLoader._waitForLibrariesToReplicate() end 310 | 311 | return ModuleLoader 312 | -------------------------------------------------------------------------------- /src/version/build-meta-head.lua: -------------------------------------------------------------------------------- 1 | --- @module build-meta 2 | -- This module is created automatically by the Makefile when Modules is built 3 | -- This content is prepended to build-meta.lua 4 | return {} 5 | -------------------------------------------------------------------------------- /src/version/init.lua: -------------------------------------------------------------------------------- 1 | --- @module version.test 2 | -- Tests for version module 3 | 4 | local VERSION = { 5 | SOFTWARE_NAME = "Modules"; 6 | MAJOR = 1; 7 | MINOR = 1; 8 | PATCH = 1; 9 | PRERELEASE = {}; 10 | BUILD_METADATA = script:FindFirstChild("build-meta") 11 | and require(script["build-meta"]) or {}; 12 | } 13 | 14 | local function toStringCopy(t) 15 | local t2 = {} 16 | for k, v in pairs(t) do 17 | t2[k] = tostring(v) 18 | end 19 | return t2 20 | end 21 | 22 | local function getVersionName(v) 23 | local PRERELEASE = toStringCopy(v.PRERELEASE) 24 | local BUILD_METADATA = toStringCopy(v.BUILD_METADATA) 25 | 26 | return ("%s %d.%d.%d%s%s"):format( 27 | v.SOFTWARE_NAME, v.MAJOR, v.MINOR, v.PATCH, 28 | #PRERELEASE > 0 and "-" .. table.concat(PRERELEASE, ".") or "", 29 | #BUILD_METADATA > 0 and "+" .. table.concat(BUILD_METADATA, ".") or "" 30 | ) 31 | end 32 | 33 | VERSION.NAME = getVersionName(VERSION) 34 | 35 | return VERSION 36 | -------------------------------------------------------------------------------- /test.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Modules", 3 | "tree": { 4 | "$className": "DataModel", 5 | "ReplicatedStorage": { 6 | "$className": "ReplicatedStorage", 7 | "$ignoreUnknownInstances": true, 8 | "$path": "test/ReplicatedStorage", 9 | 10 | "Modules": { 11 | "$path": "src" 12 | } 13 | }, 14 | "ServerScriptService": { 15 | "$className": "ServerScriptService", 16 | "$ignoreUnknownInstances": true, 17 | "$path": "test/ServerScriptService" 18 | }, 19 | "StarterPlayer": { 20 | "$className": "StarterPlayer", 21 | "$ignoreUnknownInstances": true, 22 | "StarterPlayerScripts": { 23 | "$className": "StarterPlayerScripts", 24 | "$path": "test/StarterPlayer/StarterPlayerScripts" 25 | } 26 | }, 27 | "Players": { 28 | "$className": "Players", 29 | "$ignoreUnknownInstances": true, 30 | "$properties": { 31 | "CharacterAutoLoads": false 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /test/ReplicatedStorage/ModulesTest/Event.test.lua: -------------------------------------------------------------------------------- 1 | --- @module Event.test 2 | -- Tests for Event class 3 | 4 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 5 | local Modules = ReplicatedStorage:WaitForChild("Modules") 6 | local Event = require(Modules:WaitForChild("Event")) 7 | 8 | local EventTests = {} 9 | 10 | EventTests["test_Event"] = function () 11 | -- Utility 12 | local myFunctionDidRun = false 13 | local function myFunction() 14 | myFunctionDidRun = true 15 | end 16 | 17 | -- Event.new 18 | local event = Event.new() 19 | assert(type(event) ~= "nil") 20 | 21 | -- Event:connect 22 | local connection = event:connect(myFunction) 23 | assert(type(connection) ~= "nil", "Event:connect should return a connection") 24 | 25 | -- Event:fire 26 | event:fire() 27 | assert(myFunctionDidRun, "Event:fire should run connected functions") 28 | 29 | -- Connection:disconnect() 30 | myFunctionDidRun = false 31 | connection:disconnect() 32 | connection = nil 33 | event:fire() 34 | assert(not myFunctionDidRun, "Event:fire should not run disconnected functions") 35 | 36 | -- Event:cleanup 37 | event:cleanup() 38 | event = nil 39 | end 40 | 41 | return EventTests 42 | -------------------------------------------------------------------------------- /test/ReplicatedStorage/ModulesTest/Maid.test.lua: -------------------------------------------------------------------------------- 1 | --- @module Maid.test 2 | -- Tests for Maid class 3 | 4 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 5 | local Modules = ReplicatedStorage:WaitForChild("Modules") 6 | local Maid = require(Modules:WaitForChild("Maid")) 7 | 8 | local MaidTests = {} 9 | 10 | MaidTests["test_Maid:addTask(function)"] = function () 11 | -- Setup 12 | local myFunctionDidRun = false 13 | local function myFunction() 14 | myFunctionDidRun = true 15 | end 16 | 17 | -- Maid 18 | local maid = Maid.new() 19 | maid:addTask(myFunction) 20 | maid:cleanup() 21 | maid = nil 22 | 23 | assert(myFunctionDidRun, "Maid should call a function added as a task") 24 | end 25 | 26 | MaidTests["test_Maid:addTask(connection)"] = function () 27 | -- Setup 28 | local myFunctionDidRun = false 29 | local function myFunction() 30 | myFunctionDidRun = true 31 | end 32 | 33 | local object = Instance.new("Folder") 34 | object.Name = "A" 35 | local connection = object.Changed:Connect(myFunction) 36 | 37 | -- Maid 38 | local maid = Maid.new() 39 | maid:addTask(connection) 40 | maid:cleanup() 41 | maid = nil 42 | 43 | -- Cause Changed to fire 44 | object.Name = "B" 45 | 46 | assert(not myFunctionDidRun, "Maid should disconnect a connection added as a task") 47 | end 48 | 49 | MaidTests["test_Maid:addTask(Instance)"] = function () 50 | -- Setup 51 | local object = Instance.new("Folder") 52 | object.Parent = workspace 53 | 54 | -- Maid 55 | local maid = Maid.new() 56 | maid:addTask(object) 57 | maid:cleanup() 58 | maid = nil 59 | 60 | assert(not object.Parent, "Maid should disconnect a connection added as a task") 61 | end 62 | 63 | MaidTests["test_Maid:addTask(cleanup)"] = function () 64 | -- Setup 65 | local myFunctionDidRun = false 66 | local function myFunction() 67 | myFunctionDidRun = true 68 | end 69 | 70 | -- Maid.new 71 | local maid = Maid.new() 72 | maid:addTask({ cleanup = myFunction }) 73 | maid:cleanup() 74 | maid = nil 75 | 76 | assert(myFunctionDidRun, "Maid should call cleanup on tasks that have it") 77 | end 78 | 79 | return MaidTests 80 | -------------------------------------------------------------------------------- /test/ReplicatedStorage/ModulesTest/Modules.test/PlainModule.lua: -------------------------------------------------------------------------------- 1 | --- @module PlainModule 2 | -- A simple Module used by Modules.test 3 | 4 | local mod = {} 5 | 6 | mod.name = "PlainModule" 7 | 8 | return mod 9 | -------------------------------------------------------------------------------- /test/ReplicatedStorage/ModulesTest/Modules.test/init.lua: -------------------------------------------------------------------------------- 1 | --- @module Modules.test 2 | -- Tests for ModuleLoader module 3 | 4 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 5 | local ServerScriptService = game:GetService("ServerScriptService") 6 | local Modules = ReplicatedStorage:WaitForChild("Modules") 7 | 8 | local ModuleLoader = require(Modules) 9 | 10 | local ModuleLoaderTests = {} 11 | 12 | ModuleLoaderTests["test_ModuleLoader.require(ModuleScript)"] = function () 13 | local scr = script.PlainModule 14 | local PlainModule = require(scr) 15 | assert(ModuleLoader(scr) == PlainModule, 16 | "Calling ModuleLoader.__call with a ModuleScript should return the same as require") 17 | assert(ModuleLoader.require(scr) == PlainModule, 18 | "Calling ModuleLoader.require with a ModuleScript should return the same as require") 19 | end 20 | 21 | ModuleLoaderTests["test_ModuleLoader.require()_client"] = function () 22 | local someClientNamespace = assert(ReplicatedStorage:FindFirstChild("SomeClientNamespace"), "SomeClientNamespace missing") 23 | local someClientModule = assert(someClientNamespace:FindFirstChild("SomeClientModule"), "SomeClientModule missing") 24 | local someClientModuleWithAPeriod = assert(someClientNamespace:FindFirstChild("SomeClientModule.WithAPeriod"), "SomeClientModule.WithAPeriod missing") 25 | 26 | local SomeClientModule = require(someClientModule) 27 | assert(ModuleLoader("SomeClientNamespace:SomeClientModule") == SomeClientModule, 28 | "Calling ModuleLoader.require should properly find modules in client namespaces") 29 | 30 | local SomeClientModuleWithAPeriod = require(someClientModuleWithAPeriod) 31 | local SomeClientModuleWithAPeriod_ = ModuleLoader("SomeClientNamespace:SomeClientModule%.WithAPeriod") 32 | assert(SomeClientModuleWithAPeriod_ == SomeClientModuleWithAPeriod, 33 | "Calling ModuleLoader.require should properly find modules whose names have %-escaped periods") 34 | end 35 | 36 | ModuleLoaderTests["test_ModuleLoader.require()_server"] = function () 37 | local someServerNamespace = assert(ServerScriptService:FindFirstChild("SomeServerNamespace"), "SomeServerNamespace missing") 38 | local someServerModule = assert(someServerNamespace:FindFirstChild("SomeServerModule"), "SomeServerModule missing") 39 | 40 | local SomeServerModule = require(someServerModule) 41 | assert(ModuleLoader("SomeServerNamespace:SomeServerModule") == SomeServerModule, 42 | "Calling ModuleLoader.require should properly find modules in server namespaces") 43 | end 44 | 45 | ModuleLoaderTests["test_ModuleLoader.require()_server_replicated"] = function () 46 | -- Normally this is in ServerScriptService.SomeServerNamespace.Replicated, but the module should 47 | -- have moved this to ReplicatedStorage.SomeServerNamespace.SomeReplicatedBit ! 48 | local someServerNamespaceReplicated = assert(ReplicatedStorage:FindFirstChild("SomeServerNamespace"), "SomeServerNamespace (replicated) missing") 49 | local someReplicatedBit = assert(someServerNamespaceReplicated:FindFirstChild("SomeReplicatedBit"), "SomeReplicatedBit missing") 50 | 51 | local SomeReplicatedBit = require(someReplicatedBit) 52 | assert(ModuleLoader("SomeServerNamespace:SomeReplicatedBit") == SomeReplicatedBit, 53 | "Calling ModuleLoader.require should properly find replicated parts of server namespaces") 54 | end 55 | 56 | return ModuleLoaderTests 57 | -------------------------------------------------------------------------------- /test/ReplicatedStorage/ModulesTest/StateMachine.test.lua: -------------------------------------------------------------------------------- 1 | --- @module StateMachine.test 2 | -- Tests for StateMachine class 3 | 4 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 5 | local Modules = ReplicatedStorage:WaitForChild("Modules") 6 | local StateMachine = require(Modules:WaitForChild("StateMachine")) 7 | 8 | local StateMachineTests = {} 9 | 10 | StateMachineTests["test_StateMachine"] = function () 11 | -- Setup 12 | local onEnterDidRun = false 13 | local onLeaveDidRun = false 14 | 15 | -- StateMachine 16 | local sm = StateMachine.new() 17 | local state1 = sm:newState("state1") 18 | state1.onLeave:connect(function () 19 | onLeaveDidRun = true 20 | end) 21 | local state2 = sm:newState("state2") 22 | state2.onEnter:connect(function () 23 | onEnterDidRun = true 24 | end) 25 | 26 | assert(sm:hasState("state1"), "StateMachine:newState should add the constructed state") 27 | 28 | -- First, transition to first state 29 | sm:transition(state1) 30 | assert(sm:isInState(state1), "StateMachine:transition should transition the StateMachine to the given state") 31 | 32 | -- Then, transition to second state 33 | sm:transition(state2) 34 | 35 | -- Check that things worked fine 36 | assert(onLeaveDidRun, "State.onLeave should fire when a state is left") 37 | assert(onEnterDidRun, "State.onEnter should fire when a state is entered") 38 | 39 | sm:cleanup() 40 | end 41 | 42 | StateMachineTests["test_StateMachine:newSubmachine(state)"] = function () 43 | -- StateMachine 44 | local sm = StateMachine.new() 45 | 46 | -- States 47 | local state1 = sm:newState("state1") 48 | local state2 = sm:newState("state2") 49 | sm:transition(state1) 50 | 51 | -- Sub-StateMachines 52 | local subm1, subm1inactive, subm1active = sm:newSubmachine(state1) 53 | --local subm1state1 = subm1:newState("subm1state1") 54 | local subm2, subm2inactive, subm2active = sm:newSubmachine(state2) 55 | local subm2state1 = subm1:newState("subm2state1") 56 | 57 | assert(subm1:isInState(subm1active), "Submachine should start in Active state when parent StateMachine is in the submachine state") 58 | assert(subm2:isInState(subm2inactive), "Submachine should start in Inactive state when parent StateMachine is not in the submachine state") 59 | 60 | sm:transition(state2) 61 | assert(subm1:isInState(subm1inactive), "After transitioning away from parent state, submachine should transition to Inactive state") 62 | assert(subm2:isInState(subm2active), "After transitioning to parent state, submachine should transition to Active state") 63 | 64 | subm2:transition(subm2state1) 65 | assert(subm2:isInState(subm2state1), "Submachine should be able to transition while parent state is active") 66 | 67 | sm:transition(state1) 68 | assert(subm2:isInState(subm2inactive), "Submachine should return to Inactivate state when parent StateMachine State is left") 69 | end 70 | 71 | return StateMachineTests 72 | -------------------------------------------------------------------------------- /test/ReplicatedStorage/ModulesTest/class.test.lua: -------------------------------------------------------------------------------- 1 | --- Tests for class module 2 | --@module class.test 3 | 4 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 5 | local Modules = ReplicatedStorage:WaitForChild("Modules") 6 | local class = require(Modules:WaitForChild("class")) 7 | 8 | -- A simple, idiomatic Lua class 9 | local MyClass = {} 10 | MyClass.name = "MyClass" 11 | MyClass.__index = MyClass 12 | 13 | function MyClass.new() 14 | return setmetatable({name = "MyClass(object)"}, MyClass) 15 | end 16 | 17 | -- A simple, idiomatic Lua subclass 18 | local MySubclass = setmetatable({}, MyClass) 19 | MySubclass.name = "MySubclass" 20 | MySubclass.__index = MySubclass 21 | 22 | function MySubclass.new() 23 | local self = setmetatable(MyClass.new(), MySubclass) 24 | self.name = "MySubclass(object)" 25 | return self 26 | end 27 | 28 | -- Yet another class 29 | local MyOtherClass = {} 30 | MyOtherClass.name = "MyOtherClass" 31 | MyOtherClass.__index = MyOtherClass 32 | 33 | function MyOtherClass.new() 34 | return setmetatable({name = "MyOtherClass"}, MyOtherClass) 35 | end 36 | 37 | -- Begin tests 38 | 39 | local classTests = {} 40 | 41 | classTests["test_class.classOf"] = function () 42 | local myClassObject = MyClass.new() 43 | local myOtherClassObject = MyOtherClass.new() 44 | local mySubclassObject = MySubclass.new() 45 | 46 | assert(class.classOf(myClassObject) == MyClass, "class.classOf should identify class of basic object") 47 | assert(class.classOf(myOtherClassObject) == MyOtherClass, "class.classOf should identify class of basic object") 48 | assert(class.classOf(mySubclassObject) == MySubclass, "class.classOf should respect subclass relationship") 49 | end 50 | 51 | classTests["test_class.getSuperclass"] = function () 52 | assert(class.getSuperclass(MySubclass) == MyClass, "class.getSuperclass should respect subclass relationship") 53 | assert(type(class.getSuperclass(MyClass)) == "nil", "class.getSuperclass should return nil when there is no superclass") 54 | assert(type(class.getSuperclass(MyOtherClass)) == "nil", "class.getSuperclass should return nil when there is no superclass") 55 | end 56 | 57 | classTests["test_class.extends"] = function () 58 | assert( class.extends(MySubclass, MyClass), "class.extends should respect subclass relationship") 59 | assert(not class.extends(MyClass, MyOtherClass), "class.extends should respect subclass relationship in correct direction") 60 | end 61 | 62 | classTests["test_class.instanceOf"] = function () 63 | local myClassObject = MyClass.new() 64 | local myOtherClassObject = MyOtherClass.new() 65 | local mySubclassObject = MySubclass.new() 66 | 67 | assert( class.instanceOf(myClassObject, MyClass), "class.instanceOf should identify class of basic object") 68 | assert( class.instanceOf(mySubclassObject, MyClass), "class.instanceOf should respect subclass relationship") 69 | assert(not class.instanceOf(myOtherClassObject, MyClass), "class.instanceOf should return false if the object is not an instance of the class") 70 | assert(not class.instanceOf(myClassObject, MyOtherClass), "class.instanceOf should return false if the object is not an instance of the class") 71 | assert(not class.instanceOf(mySubclassObject, MyOtherClass), "class.instanceOf should return false if the object is not an instance of the class") 72 | end 73 | 74 | return classTests 75 | -------------------------------------------------------------------------------- /test/ReplicatedStorage/ModulesTest/version.test.lua: -------------------------------------------------------------------------------- 1 | --- @module version 2 | -- Describes the current version of Modules 3 | 4 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 5 | local Modules = ReplicatedStorage:WaitForChild("Modules") 6 | local version = require(Modules:WaitForChild("version")) 7 | 8 | local versionTests = {} 9 | 10 | versionTests["test_version"] = function () 11 | assert(version.MAJOR >= 0, "version.MAJOR should be positive") 12 | assert(version.MINOR >= 0, "version.MINOR should be positive") 13 | assert(version.PATCH >= 0, "version.PATCH should be positive") 14 | end 15 | 16 | return versionTests 17 | -------------------------------------------------------------------------------- /test/ReplicatedStorage/SomeClientNamespace/SomeClientModule.WithAPeriod.lua: -------------------------------------------------------------------------------- 1 | return "SomeClientModule.WithAPeriod" 2 | -------------------------------------------------------------------------------- /test/ReplicatedStorage/SomeClientNamespace/SomeClientModule.lua: -------------------------------------------------------------------------------- 1 | return "SomeClientModule" 2 | -------------------------------------------------------------------------------- /test/ReplicatedStorage/TestRunner.lua: -------------------------------------------------------------------------------- 1 | --- Given a table of functions ("tests"), runs each function and records the results 2 | --@classmod TestRunner 3 | 4 | local TestRunner = {} 5 | TestRunner.__index = TestRunner 6 | TestRunner.MODULESCRIPT_NAME_PATTERN = "%.?[tT]est$" 7 | TestRunner.FUNCTION_NAME_PATTERN_PREFIX = "^[tT]est_?" 8 | TestRunner.FUNCTION_NAME_PATTERN_POSTFIX = "_?[tT]est" 9 | 10 | --- Determines if the given name of a function indicates that it is 11 | -- a test function. 12 | function TestRunner.functionNameIndicatesTest(name) 13 | return name:match(TestRunner.FUNCTION_NAME_PATTERN_PREFIX) 14 | or name:match(TestRunner.FUNCTION_NAME_PATTERN_POSTFIX) 15 | end 16 | 17 | --- Determines whether the given object is a ModuleScript containing tests 18 | function TestRunner.isTestModule(object) 19 | return object:IsA("ModuleScript") and object.Name:match(TestRunner.MODULESCRIPT_NAME_PATTERN) 20 | end 21 | 22 | --- Recurses an object for test module scripts and calls foundTestModuleScript for each one found 23 | function TestRunner.recurseForTestModules(object, foundTestModuleScript) 24 | if TestRunner.isTestModule(object) then 25 | foundTestModuleScript(object) 26 | end 27 | for _, child in pairs(object:GetChildren()) do 28 | TestRunner.recurseForTestModules(child, foundTestModuleScript) 29 | end 30 | end 31 | 32 | --- Constructs a TestRunner using tests gathered from a root object, 33 | -- which is recursed for any ModuleScripts whose names end in the given 34 | -- pattern, "test"/"Test" or ".test"/".Test" 35 | function TestRunner.gather(object) 36 | local tests = {} --[name] = func 37 | local testNames = {} --[func] = name 38 | local numTests = 0 39 | 40 | -- Add tests to the table 41 | TestRunner.recurseForTestModules(object, function (testModule) 42 | local testsToAdd = require(testModule) 43 | assert(type(testsToAdd) == "table", ("%s should return a table of test functions, returned %s: %s"):format( 44 | testModule:GetFullName(), type(testsToAdd), tostring(testsToAdd) 45 | )) 46 | local testsInThisModule = 0 47 | for name, func in pairs(testsToAdd) do 48 | if type(func) == "function" and TestRunner.functionNameIndicatesTest(name) then 49 | tests[name] = assert(not tests[name] and func, ("Test with name %s already exists"):format(name)) 50 | testNames[func] = assert(not testNames[func], ("Duplicate tests: %s and %s"):format(name or "nil", testNames[func] or "nil")) 51 | numTests = numTests + 1 52 | testsInThisModule = testsInThisModule + 1 53 | end 54 | end 55 | assert(testsInThisModule > 0, ("%s should contain at least one test function"):format(testModule:GetFullName())) 56 | end) 57 | 58 | assert(numTests > 0, ("TestRunner.gather found no tests in %s"):format(object:GetFullName())) 59 | 60 | return TestRunner.new(tests) 61 | end 62 | 63 | --- Construct a new TestRunner 64 | function TestRunner.new(tests) 65 | local self = setmetatable({ 66 | tests = assert(type(tests) == "table" and tests); 67 | testNames = {}; 68 | ran = false; 69 | passed = 0; 70 | failed = 0; 71 | errors = {}; 72 | retvals = {}; 73 | printPrefix = nil; 74 | }, TestRunner) 75 | self.printFunc = function (...) 76 | return self:_print(...) 77 | end 78 | 79 | -- Gather all test names, then sort 80 | for name, _func in pairs(tests) do 81 | table.insert(self.testNames, name) 82 | end 83 | table.sort(self.testNames) 84 | 85 | return self 86 | end 87 | 88 | --- Runs the tests provided to this TestRunner (can only be done once) 89 | function TestRunner:run() 90 | assert(not self.ran, "Tests already run") 91 | self.ran = true 92 | for _i, name in pairs(self.testNames) do 93 | local func = self.tests[name] 94 | self:_runTest(name, func) 95 | end 96 | end 97 | 98 | --- A print override 99 | function TestRunner:_print(...) 100 | print(self.printPrefix or "[TestRunner]", ...) 101 | end 102 | 103 | --- Runs a specific test and records the results 104 | function TestRunner:_runTest(name, func) 105 | self:_print(("\t=== %s ==="):format(name)) 106 | -- Override print inthe test function 107 | --getfenv(func).print = self.printFunc 108 | -- Reset the print flag 109 | local retvals = {xpcall(func, function (err) 110 | self:_print(debug.traceback(err, 2)) 111 | end)} 112 | if table.remove(retvals, 1) then 113 | self:_print("Pass") 114 | self.passed = self.passed + 1 115 | self.retvals[name] = retvals 116 | else 117 | self.failed = self.failed + 1 118 | --self.errors[name] = retvals[1] 119 | self:_print("Fail") --\t" .. self.errors[name]) 120 | end 121 | end 122 | 123 | --- Reports the results of the tests ran by this TestRunner 124 | function TestRunner:report() 125 | assert(self.ran, "Tests not run") 126 | self:_print("\t====== RESULTS ======") 127 | if self.passed > 0 then 128 | self:_print(("%d tests passed"):format(self.passed)) 129 | end 130 | if self.failed > 0 then 131 | self:_print(("%d tests failed"):format(self.failed)) 132 | end 133 | end 134 | 135 | --- Run tests and report results 136 | function TestRunner:runAndReport() 137 | self:run() 138 | self:report() 139 | end 140 | 141 | return TestRunner 142 | -------------------------------------------------------------------------------- /test/ServerScriptService/ModulesTest/RunTests.server.lua: -------------------------------------------------------------------------------- 1 | --- Invokes TestRunner with the tests in ServerTests 2 | 3 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 4 | 5 | local TestRunner = require(ReplicatedStorage:WaitForChild("TestRunner")) 6 | 7 | local testContainer = ReplicatedStorage:WaitForChild("ModulesTest") 8 | 9 | local function main() 10 | local testRunner = TestRunner.gather(testContainer) 11 | testRunner:runAndReport() 12 | end 13 | main() 14 | -------------------------------------------------------------------------------- /test/ServerScriptService/ModulesTest/ServerTests/Modules.test.lua: -------------------------------------------------------------------------------- 1 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules")) 2 | 3 | local server = {} 4 | 5 | function server.test_version() 6 | assert(require("version") == require.VERSION) 7 | end 8 | 9 | function server.test_hello_world() 10 | print("Hello, server") 11 | end 12 | 13 | function server.test_kaboom() 14 | --error("Kaboom") 15 | end 16 | 17 | return server 18 | -------------------------------------------------------------------------------- /test/ServerScriptService/SomeServerNamespace/Replicated/SomeReplicatedBit.lua: -------------------------------------------------------------------------------- 1 | return "SomeReplicatedBit" 2 | -------------------------------------------------------------------------------- /test/ServerScriptService/SomeServerNamespace/SomeServerModule.lua: -------------------------------------------------------------------------------- 1 | return "SomeServerModule" 2 | -------------------------------------------------------------------------------- /test/StarterPlayer/StarterPlayerScripts/ClientTests/Modules.test.lua: -------------------------------------------------------------------------------- 1 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules")) 2 | 3 | local ModulesClient = {} 4 | 5 | function ModulesClient.test_version() 6 | assert(require("version") == require.VERSION) 7 | end 8 | 9 | return ModulesClient 10 | -------------------------------------------------------------------------------- /test/StarterPlayer/StarterPlayerScripts/RunTests.client.lua: -------------------------------------------------------------------------------- 1 | --- Invokes TestRunner with the tests in ClientTests 2 | local TestRunner = require(game:GetService("ReplicatedStorage"):WaitForChild("TestRunner")) 3 | 4 | local testContainer = script.Parent.ClientTests 5 | 6 | local function main() 7 | local testRunner = TestRunner.gather(testContainer) 8 | testRunner:runAndReport() 9 | end 10 | main() 11 | --------------------------------------------------------------------------------