From aeedea2fcb479f6fb9f578624c3479149984993e Mon Sep 17 00:00:00 2001 From: Ukendio Date: Wed, 18 Feb 2026 01:29:34 +0100 Subject: [PATCH] Add Jabby module --- .../client/apps/entity/add_component.luau | 106 + modules/Jabby/client/apps/entity/editor.luau | 118 + modules/Jabby/client/apps/entity/init.luau | 81 + .../entity/systems/obtain_entity_data.luau | 85 + modules/Jabby/client/apps/entity/widget.luau | 414 ++ modules/Jabby/client/apps/home/init.luau | 36 + .../apps/home/systems/get_core_data.luau | 93 + modules/Jabby/client/apps/home/widget.luau | 183 + .../client/apps/overview_scheduler/init.luau | 68 + .../apps/overview_scheduler/stack_bar.luau | 46 + .../systems/get_scheduler_data.luau | 98 + .../apps/overview_scheduler/widget.luau | 412 ++ modules/Jabby/client/apps/registry/init.luau | 154 + .../systems/highlight_workspace_entity.luau | 34 + .../registry/systems/obtain_query_data.luau | 128 + .../systems/send_workspace_entity.luau | 33 + .../apps/registry/systems/validate_query.luau | 98 + .../Jabby/client/apps/registry/widget.luau | 503 ++ modules/Jabby/client/apps/system/init.luau | 81 + .../client/apps/system/systems/replicate.luau | 97 + .../client/apps/system/watch_tracker.luau | 235 + modules/Jabby/client/apps/system/widget.luau | 34 + modules/Jabby/client/components/tooltip.luau | 60 + .../virtualscroller_horizontal.luau | 185 + modules/Jabby/client/init.luau | 19 + modules/Jabby/client/spawn_app.luau | 36 + modules/Jabby/examples/example.luau | 46 + modules/Jabby/jecs.luau | 4115 +++++++++++++++++ modules/Jabby/module.luau | 60 + modules/Jabby/modules/average.luau | 11 + modules/Jabby/modules/convert_units.luau | 39 + modules/Jabby/modules/hash_connector.luau | 5 + modules/Jabby/modules/lon.luau | 1044 +++++ modules/Jabby/modules/loop.luau | 107 + modules/Jabby/modules/net.luau | 202 + modules/Jabby/modules/queue.luau | 115 + modules/Jabby/modules/remotes.luau | 359 ++ modules/Jabby/modules/reverse_connector.luau | 11 + modules/Jabby/modules/signal.luau | 75 + modules/Jabby/modules/traffic_check.luau | 82 + modules/Jabby/modules/types.luau | 117 + modules/Jabby/modules/videx.luau | 71 + modules/Jabby/modules/vm_id.luau | 14 + modules/Jabby/server/init.luau | 42 + modules/Jabby/server/public.luau | 16 + modules/Jabby/server/query_parser.luau | 402 ++ modules/Jabby/server/scheduler.luau | 197 + modules/Jabby/server/systems/entity.luau | 294 ++ .../Jabby/server/systems/mouse_pointer.luau | 209 + modules/Jabby/server/systems/ping.luau | 28 + .../Jabby/server/systems/replicate_core.luau | 80 + .../server/systems/replicate_registry.luau | 503 ++ .../server/systems/replicate_scheduler.luau | 104 + .../systems/replicate_system_watch.luau | 175 + modules/Jabby/server/watch.luau | 129 + modules/Jabby/server/world_hook.luau | 75 + .../ui/components/display/accordion.luau | 121 + .../ui/components/display/background.luau | 46 + .../Jabby/ui/components/display/checkbox.luau | 64 + .../Jabby/ui/components/display/divider.luau | 23 + .../Jabby/ui/components/display/pages.luau | 118 + modules/Jabby/ui/components/display/pane.luau | 70 + .../ui/components/display/resizeable_bar.luau | 207 + .../ui/components/display/scroll_frame.luau | 22 + .../Jabby/ui/components/display/snapping.luau | 123 + .../ui/components/display/tablesheet.luau | 179 + .../ui/components/display/typography.luau | 86 + .../ui/components/display/widget/borders.luau | 229 + .../ui/components/display/widget/init.luau | 299 ++ .../ui/components/display/widget/topbar.luau | 150 + .../Jabby/ui/components/graph/bargraph.luau | 77 + modules/Jabby/ui/components/graph/graph.luau | 53 + .../Jabby/ui/components/graph/linegraph.luau | 74 + .../ui/components/interactable/button.luau | 160 + .../ui/components/interactable/select.luau | 166 + .../ui/components/interactable/textfield.luau | 175 + .../Jabby/ui/components/util/container.luau | 25 + modules/Jabby/ui/components/util/gap.luau | 31 + modules/Jabby/ui/components/util/list.luau | 55 + modules/Jabby/ui/components/util/padding.luau | 41 + modules/Jabby/ui/components/util/portal.luau | 102 + .../ui/components/util/rounded_frame.luau | 201 + modules/Jabby/ui/components/util/row.luau | 56 + modules/Jabby/ui/components/util/shadow.luau | 20 + .../ui/components/util/virtualscroller.luau | 189 + modules/Jabby/ui/init.luau | 67 + modules/Jabby/ui/libraries/apcaw3.luau | 350 ++ modules/Jabby/ui/libraries/cascade.luau | 62 + modules/Jabby/ui/libraries/context.luau | 57 + modules/Jabby/ui/libraries/delay.luau | 26 + modules/Jabby/ui/libraries/oklab.luau | 434 ++ modules/Jabby/ui/libraries/store.luau | 101 + modules/Jabby/ui/util/anim.luau | 27 + modules/Jabby/ui/util/constants.luau | 0 modules/Jabby/ui/util/consume.luau | 10 + modules/Jabby/ui/util/contrast.luau | 155 + modules/Jabby/ui/util/oklch.luau | 28 + modules/Jabby/ui/util/reduced_motion.luau | 3 + modules/Jabby/ui/util/theme.luau | 253 + modules/Jabby/vide/action.luau | 25 + modules/Jabby/vide/apply.luau | 177 + modules/Jabby/vide/batch.luau | 26 + modules/Jabby/vide/bind.luau | 105 + modules/Jabby/vide/changed.luau | 20 + modules/Jabby/vide/cleanup.luau | 43 + modules/Jabby/vide/context.luau | 77 + modules/Jabby/vide/create.luau | 84 + modules/Jabby/vide/defaults.luau | 114 + modules/Jabby/vide/derive.luau | 20 + modules/Jabby/vide/effect.luau | 14 + modules/Jabby/vide/flags.luau | 7 + modules/Jabby/vide/graph.luau | 300 ++ modules/Jabby/vide/init.luau | 121 + modules/Jabby/vide/maps.luau | 219 + modules/Jabby/vide/mount.luau | 13 + modules/Jabby/vide/read.luau | 7 + modules/Jabby/vide/root.luau | 39 + modules/Jabby/vide/show.luau | 18 + modules/Jabby/vide/source.luau | 31 + modules/Jabby/vide/spring.luau | 311 ++ modules/Jabby/vide/switch.luau | 65 + modules/Jabby/vide/throw.luau | 7 + modules/Jabby/vide/untrack.luau | 27 + 123 files changed, 18769 insertions(+) create mode 100644 modules/Jabby/client/apps/entity/add_component.luau create mode 100644 modules/Jabby/client/apps/entity/editor.luau create mode 100644 modules/Jabby/client/apps/entity/init.luau create mode 100644 modules/Jabby/client/apps/entity/systems/obtain_entity_data.luau create mode 100644 modules/Jabby/client/apps/entity/widget.luau create mode 100644 modules/Jabby/client/apps/home/init.luau create mode 100644 modules/Jabby/client/apps/home/systems/get_core_data.luau create mode 100644 modules/Jabby/client/apps/home/widget.luau create mode 100644 modules/Jabby/client/apps/overview_scheduler/init.luau create mode 100644 modules/Jabby/client/apps/overview_scheduler/stack_bar.luau create mode 100644 modules/Jabby/client/apps/overview_scheduler/systems/get_scheduler_data.luau create mode 100644 modules/Jabby/client/apps/overview_scheduler/widget.luau create mode 100644 modules/Jabby/client/apps/registry/init.luau create mode 100644 modules/Jabby/client/apps/registry/systems/highlight_workspace_entity.luau create mode 100644 modules/Jabby/client/apps/registry/systems/obtain_query_data.luau create mode 100644 modules/Jabby/client/apps/registry/systems/send_workspace_entity.luau create mode 100644 modules/Jabby/client/apps/registry/systems/validate_query.luau create mode 100644 modules/Jabby/client/apps/registry/widget.luau create mode 100644 modules/Jabby/client/apps/system/init.luau create mode 100644 modules/Jabby/client/apps/system/systems/replicate.luau create mode 100644 modules/Jabby/client/apps/system/watch_tracker.luau create mode 100644 modules/Jabby/client/apps/system/widget.luau create mode 100644 modules/Jabby/client/components/tooltip.luau create mode 100644 modules/Jabby/client/components/virtualscroller_horizontal.luau create mode 100644 modules/Jabby/client/init.luau create mode 100644 modules/Jabby/client/spawn_app.luau create mode 100644 modules/Jabby/examples/example.luau create mode 100644 modules/Jabby/jecs.luau create mode 100644 modules/Jabby/module.luau create mode 100644 modules/Jabby/modules/average.luau create mode 100644 modules/Jabby/modules/convert_units.luau create mode 100644 modules/Jabby/modules/hash_connector.luau create mode 100644 modules/Jabby/modules/lon.luau create mode 100644 modules/Jabby/modules/loop.luau create mode 100644 modules/Jabby/modules/net.luau create mode 100644 modules/Jabby/modules/queue.luau create mode 100644 modules/Jabby/modules/remotes.luau create mode 100644 modules/Jabby/modules/reverse_connector.luau create mode 100644 modules/Jabby/modules/signal.luau create mode 100644 modules/Jabby/modules/traffic_check.luau create mode 100644 modules/Jabby/modules/types.luau create mode 100644 modules/Jabby/modules/videx.luau create mode 100644 modules/Jabby/modules/vm_id.luau create mode 100644 modules/Jabby/server/init.luau create mode 100644 modules/Jabby/server/public.luau create mode 100644 modules/Jabby/server/query_parser.luau create mode 100644 modules/Jabby/server/scheduler.luau create mode 100644 modules/Jabby/server/systems/entity.luau create mode 100644 modules/Jabby/server/systems/mouse_pointer.luau create mode 100644 modules/Jabby/server/systems/ping.luau create mode 100644 modules/Jabby/server/systems/replicate_core.luau create mode 100644 modules/Jabby/server/systems/replicate_registry.luau create mode 100644 modules/Jabby/server/systems/replicate_scheduler.luau create mode 100644 modules/Jabby/server/systems/replicate_system_watch.luau create mode 100644 modules/Jabby/server/watch.luau create mode 100644 modules/Jabby/server/world_hook.luau create mode 100644 modules/Jabby/ui/components/display/accordion.luau create mode 100644 modules/Jabby/ui/components/display/background.luau create mode 100644 modules/Jabby/ui/components/display/checkbox.luau create mode 100644 modules/Jabby/ui/components/display/divider.luau create mode 100644 modules/Jabby/ui/components/display/pages.luau create mode 100644 modules/Jabby/ui/components/display/pane.luau create mode 100644 modules/Jabby/ui/components/display/resizeable_bar.luau create mode 100644 modules/Jabby/ui/components/display/scroll_frame.luau create mode 100644 modules/Jabby/ui/components/display/snapping.luau create mode 100644 modules/Jabby/ui/components/display/tablesheet.luau create mode 100644 modules/Jabby/ui/components/display/typography.luau create mode 100644 modules/Jabby/ui/components/display/widget/borders.luau create mode 100644 modules/Jabby/ui/components/display/widget/init.luau create mode 100644 modules/Jabby/ui/components/display/widget/topbar.luau create mode 100644 modules/Jabby/ui/components/graph/bargraph.luau create mode 100644 modules/Jabby/ui/components/graph/graph.luau create mode 100644 modules/Jabby/ui/components/graph/linegraph.luau create mode 100644 modules/Jabby/ui/components/interactable/button.luau create mode 100644 modules/Jabby/ui/components/interactable/select.luau create mode 100644 modules/Jabby/ui/components/interactable/textfield.luau create mode 100644 modules/Jabby/ui/components/util/container.luau create mode 100644 modules/Jabby/ui/components/util/gap.luau create mode 100644 modules/Jabby/ui/components/util/list.luau create mode 100644 modules/Jabby/ui/components/util/padding.luau create mode 100644 modules/Jabby/ui/components/util/portal.luau create mode 100644 modules/Jabby/ui/components/util/rounded_frame.luau create mode 100644 modules/Jabby/ui/components/util/row.luau create mode 100644 modules/Jabby/ui/components/util/shadow.luau create mode 100644 modules/Jabby/ui/components/util/virtualscroller.luau create mode 100644 modules/Jabby/ui/init.luau create mode 100644 modules/Jabby/ui/libraries/apcaw3.luau create mode 100644 modules/Jabby/ui/libraries/cascade.luau create mode 100644 modules/Jabby/ui/libraries/context.luau create mode 100644 modules/Jabby/ui/libraries/delay.luau create mode 100644 modules/Jabby/ui/libraries/oklab.luau create mode 100644 modules/Jabby/ui/libraries/store.luau create mode 100644 modules/Jabby/ui/util/anim.luau create mode 100644 modules/Jabby/ui/util/constants.luau create mode 100644 modules/Jabby/ui/util/consume.luau create mode 100644 modules/Jabby/ui/util/contrast.luau create mode 100644 modules/Jabby/ui/util/oklch.luau create mode 100644 modules/Jabby/ui/util/reduced_motion.luau create mode 100644 modules/Jabby/ui/util/theme.luau create mode 100644 modules/Jabby/vide/action.luau create mode 100644 modules/Jabby/vide/apply.luau create mode 100644 modules/Jabby/vide/batch.luau create mode 100644 modules/Jabby/vide/bind.luau create mode 100644 modules/Jabby/vide/changed.luau create mode 100644 modules/Jabby/vide/cleanup.luau create mode 100644 modules/Jabby/vide/context.luau create mode 100644 modules/Jabby/vide/create.luau create mode 100644 modules/Jabby/vide/defaults.luau create mode 100644 modules/Jabby/vide/derive.luau create mode 100644 modules/Jabby/vide/effect.luau create mode 100644 modules/Jabby/vide/flags.luau create mode 100644 modules/Jabby/vide/graph.luau create mode 100644 modules/Jabby/vide/init.luau create mode 100644 modules/Jabby/vide/maps.luau create mode 100644 modules/Jabby/vide/mount.luau create mode 100644 modules/Jabby/vide/read.luau create mode 100644 modules/Jabby/vide/root.luau create mode 100644 modules/Jabby/vide/show.luau create mode 100644 modules/Jabby/vide/source.luau create mode 100644 modules/Jabby/vide/spring.luau create mode 100644 modules/Jabby/vide/switch.luau create mode 100644 modules/Jabby/vide/throw.luau create mode 100644 modules/Jabby/vide/untrack.luau diff --git a/modules/Jabby/client/apps/entity/add_component.luau b/modules/Jabby/client/apps/entity/add_component.luau new file mode 100644 index 0000000..e4e36e2 --- /dev/null +++ b/modules/Jabby/client/apps/entity/add_component.luau @@ -0,0 +1,106 @@ +local ui = require(script.Parent.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local query_parser = require(script.Parent.Parent.Parent.Parent.server.query_parser) + +local create = vide.create +local source = vide.source +local show = vide.show + +type props = { + changes: vide.Source<{[string]: string}>, + editing: vide.Source, + adding: vide.Source, + text: vide.Source +} + +return function(props: props) + + local component_edit_text = source("") + local adding = props.adding + local editing = props.editing + local text = props.text + + return create "Folder" { + Name = "Add Component", + + show(adding, function() + return create "Frame" { + ZIndex = 1000, + Size = UDim2.new(1,16, 1, 16), + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundColor3 = Color3.new(0, 0, 0), + BackgroundTransparency = 0.5, + + Active = true, + + create "UIListLayout" { + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8) + }, + + ui.padding { + padding = UDim.new(0, 32) + }, + + ui.textfield { + size = UDim2.fromOffset(200, 30), + + placeholder = "Entity", + + oninput = component_edit_text, + }, + + create "Frame" { + Size = UDim2.new(1, 0, 0, 30), + BackgroundTransparency = 1, + AutomaticSize = Enum.AutomaticSize.Y, + + create "UIListLayout" { + HorizontalFlex = Enum.UIFlexAlignment.Fill, + FillDirection = Enum.FillDirection.Horizontal, + Padding = UDim.new(0, 8) + }, + + ui.button { + size = UDim2.fromOffset(150, 30), + text = "Edit", + activated = function() + adding(false) + editing(component_edit_text()) + end, + disabled = function() + local ok, node = pcall(query_parser, component_edit_text()) + + if not ok then return true end + if not node[1] then return true end + if node[2] then return true end + local n = node[1] + if n.type == "Relationship" then + if n.left.type == "Wildcard" then return true end + if n.right.type == "Wildcard" then return true end + end + + return false + end, + accent = true + }, + + ui.button { + size = UDim2.fromOffset(150, 30), + text = "Cancel", + activated = function() + adding(false) + end + }, + } + + + } + end) + + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/entity/editor.luau b/modules/Jabby/client/apps/entity/editor.luau new file mode 100644 index 0000000..f31dc5c --- /dev/null +++ b/modules/Jabby/client/apps/entity/editor.luau @@ -0,0 +1,118 @@ +local ui = require(script.Parent.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.Parent.vide) + +local create = vide.create +local show = vide.show + +type props = { + components: () -> {[string]: string}, + changes: vide.Source<{[string]: string}>, + editing: vide.Source, + text: vide.Source +} + +return function(props: props) + + local editing = props.editing + local text = props.text + local changes = props.changes + + return create "Folder" { + Name = "Text Editor", + + show(function() + return editing() + end, function() + return create "Frame" { + ZIndex = 1000, + Size = UDim2.new(1,16, 1, 16), + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundColor3 = Color3.new(0, 0, 0), + BackgroundTransparency = 0.5, + + Active = true, + + create "UIListLayout" { + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + VerticalFlex = Enum.UIFlexAlignment.SpaceEvenly, + Padding = UDim.new(0, 8) + }, + + ui.padding { + padding = UDim.new(0, 32) + }, + + ui.typography { + text = function() + return `Editing {editing()}` + end + }, + + create "Frame" { + Size = UDim2.fromScale(1, 0), + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + }, + + BackgroundTransparency = 1, + + ui.textfield { + size = UDim2.fromScale(1, 1), + position = UDim2.fromScale(0.5, 0.5), + anchorpoint = Vector2.new(0.5, 0.5), + + multiline = true, + code = true, + + text = text, + oninput = text, + }, + }, + + create "Frame" { + Size = UDim2.new(1, 0, 0, 30), + BackgroundTransparency = 1, + AutomaticSize = Enum.AutomaticSize.Y, + + create "UIListLayout" { + HorizontalFlex = Enum.UIFlexAlignment.Fill, + FillDirection = Enum.FillDirection.Horizontal, + Padding = UDim.new(0, 8) + }, + + ui.button { + size = UDim2.fromOffset(150, 30), + text = "Save Changes", + activated = function() + local key = editing() + changes()[key] = text() + + editing(false) + changes(changes()) + + if props.components()[key] ~= nil then return end + props.components()[key] = text() + end, + accent = true + }, + + ui.button { + size = UDim2.fromOffset(150, 30), + text = "Cancel Changes", + activated = function() + editing(false) + end + }, + } + + + } + end) + + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/entity/init.luau b/modules/Jabby/client/apps/entity/init.luau new file mode 100644 index 0000000..26c7e94 --- /dev/null +++ b/modules/Jabby/client/apps/entity/init.luau @@ -0,0 +1,81 @@ +local RunService = game:GetService("RunService") + +local vide = require(script.Parent.Parent.Parent.vide) +local loop = require(script.Parent.Parent.Parent.modules.loop) +local widget = require(script.widget) + +local source = vide.source +local cleanup = vide.cleanup + +local overview_entity = { + class_name = "app" :: "app", + name = "Entity" +} + +type props = { + host: Player | "server", + vm: number, + id: number, + entity: number, +} + +local function generate_random_query_id() + return math.random(2 ^ 31 - 1) +end + +function overview_entity.mount(props: props, destroy: () -> ()) + + local keys = source({}) + local changes = source({}) + local enable_live_updates = source(true) + local apply_changes = source(false) + local deleting = source(false) + local inspect_id = generate_random_query_id() + + -- check if the query and columns are properly + local app_loop = loop ( + "app-client-entity", + { + host = props.host, + vm = props.vm, + id = props.id, + inspect_id = inspect_id, + entity = tonumber(props.entity), + + keys = keys, + live_updates = enable_live_updates, + changes = changes, + apply_changes = apply_changes, + deleting = deleting + }, + + {i = 1}, + script.systems.obtain_entity_data + ) + + cleanup( + RunService.Heartbeat:Connect(app_loop) + ) + + return widget { + host = props.host, + vm = props.vm, + id = props.id, + inspect_id = inspect_id, + entity = props.entity, + + components = keys, + live_updates = enable_live_updates, + changes = changes, + apply_changes = apply_changes, + + delete = function() + deleting(true) + end, + + destroy = destroy + } + +end + +return overview_entity \ No newline at end of file diff --git a/modules/Jabby/client/apps/entity/systems/obtain_entity_data.luau b/modules/Jabby/client/apps/entity/systems/obtain_entity_data.luau new file mode 100644 index 0000000..e4de438 --- /dev/null +++ b/modules/Jabby/client/apps/entity/systems/obtain_entity_data.luau @@ -0,0 +1,85 @@ +local vide = require(script.Parent.Parent.Parent.Parent.Parent.vide) +local queue = require(script.Parent.Parent.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.Parent.Parent.modules.remotes) + +local effect = vide.effect +local cleanup = vide.cleanup +local batch = vide.batch + +type Context = { + host: Player | "server", + vm: number, + id: number, + inspect_id: number, + entity: number, + + keys: vide.Source<{[string]: string}>, + changes: vide.Source<{[string]: string}>, + apply_changes: vide.Source, + live_updates: () -> boolean, + deleting: () -> boolean, + +} + +return function(context: Context) + + local inspect_entity_update = queue(remotes.inspect_entity_update) + + local current_inspectid = context.inspect_id + local outgoing = { + host = context.host, + to_vm = context.vm, + } + + remotes.inspect_entity:fire( + outgoing, + context.id, + context.entity, + current_inspectid + ) + + local settings_changed = false + + effect(function() + context.live_updates() + settings_changed = true + end) + + cleanup(function() + remotes.stop_inspect_entity:fire( + outgoing, + current_inspectid + ) + end) + + return function() + + if context.apply_changes() then + remotes.update_entity:fire(outgoing, current_inspectid, context.changes()) + context.apply_changes(false) + context.changes({}) + end + + if context.deleting() then + remotes.delete_entity:fire(outgoing, current_inspectid) + end + + if settings_changed then + remotes.update_inspect_settings:fire( + outgoing, + current_inspectid, + {paused = not context.live_updates()} + ) + settings_changed = false + end + + batch(function() + for incoming, inspectid, key, value in inspect_entity_update:iter() do + if inspectid ~= current_inspectid then continue end + context.keys()[key] = value + context.keys(context.keys()) + end + end) + + end +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/entity/widget.luau b/modules/Jabby/client/apps/entity/widget.luau new file mode 100644 index 0000000..6138046 --- /dev/null +++ b/modules/Jabby/client/apps/entity/widget.luau @@ -0,0 +1,414 @@ +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") + +local ui = require(script.Parent.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local remotes = require(script.Parent.Parent.Parent.Parent.modules.remotes) +local add_component = require(script.Parent.add_component) +local editor = require(script.Parent.editor) + +local create = vide.create +local indexes = vide.indexes +local source = vide.source +local show = vide.show + +type SystemId = number + +type props = { + host: Player | "server", + vm: number, + id: number, + entity: number, + inspect_id: number, + + components: vide.Source<{[string]: string}>, + changes: vide.Source<{[string]: string}>, + live_updates: vide.Source, + apply_changes: vide.Source, + + destroy: () -> (), + delete: () -> () +} + +local mouse_location = source(Vector2.zero) + +RunService.PreRender:Connect(function() + mouse_location(UserInputService:GetMouseLocation()) +end) + +return function(props: props) + local outgoing = { + host = props.host, + to_vm = props.vm, + } + + local live_updates = props.live_updates + local changes = props.changes + + local text = source("") + local adding = source(false) + local editing = source(false :: false | string) + + local function components() + local components = {} + + for key, value in props.components() do + if value == "tag" then continue end + components[key] = value + end + + return components + end + + local function tags() + local tags = {} + + for key, value in props.components() do + if value ~= "tag" then continue end + tags[key] = value + end + + return tags + end + + local function is_removed(value: string) + return value == "nil" + end + + local function edit_component(component: string) + remotes.get_component:fire(outgoing, props.inspect_id, component) + editing(component) + + text("waiting...") + end + + vide.cleanup(remotes.return_component:connect(function(from, inspect, component, value) + if props.host ~= from.host then return end + if props.inspect_id ~= inspect then return end + + local current_value = props.changes()[component] + + if current_value then + text(current_value) + else + text(value) + end + + end)) + + return ui.widget { + title = `Entity #{props.entity}`, + subtitle = `host: {props.host} vm: {props.vm} id: {props.id}`, + + min_size = Vector2.new(300, 300), + + bind_to_close = props.destroy, + + create "Frame" { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + + create "UIListLayout" { + VerticalFlex = Enum.UIFlexAlignment.SpaceEvenly, + Padding = UDim.new(0, 8) + }, + + editor { + components = props.components, + editing = editing, + text = text, + changes = changes + }, + + add_component { + editing = edit_component, + text = text, + changes = changes, + adding = adding + }, + + ui.row { + + justifycontent = Enum.UIFlexAlignment.Fill, + + ui.button { + text = "Live Updates", + activated = function() + live_updates(not live_updates()) + end, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + HorizontalFlex = Enum.UIFlexAlignment.SpaceBetween, + Padding = UDim.new(0, 4) + }, + + ui.checkbox { + size = UDim2.fromOffset(16, 16), + layoutorder = -1, + checked = live_updates, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.None, + } + } + + }, + + ui.button { + size = UDim2.fromOffset(130, 30), + text = function() + local total = 0 + + for _, change in changes() do + total += 1 + end + + return `Apply {total} Edits` + end, + disabled = function() + return next(changes()) == nil + end, + activated = function() + props.apply_changes(true) + end + }, + + ui.button { + create "UIFlexItem" { + ItemLineAlignment = Enum.ItemLineAlignment.End, + }, + size = UDim2.fromOffset(130, 30), + + text = "Cancel changes", + disabled = function() + return next(changes()) == nil + end, + activated = function() + changes({}) + end + } + + }, + + create "ScrollingFrame" { + Size = UDim2.fromScale(1, 0), + CanvasSize = UDim2.new(), + AutomaticCanvasSize = Enum.AutomaticSize.Y, + + BackgroundColor3 = ui.theme.bg[-1], + + ScrollBarThickness = 6, + VerticalScrollBarInset = Enum.ScrollBarInset.Always, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + }, + + create "UIListLayout" { + Padding = UDim.new(0, 4) + }, + + ui.typography {text = "Components"}, + + ui.container { + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + + create "UIListLayout" { + SortOrder = Enum.SortOrder.Name, + }, + + indexes(components, function(value, key) + return ui.button { + { Name = if not string.match(key, "^%a") then "zzzz" .. key else key }, + size = UDim2.new(1, 0, 0, 32), + automaticsize = Enum.AutomaticSize.Y, + + text = "", + + corner = false, + + activated = function() + edit_component(key) + end, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalFlex = Enum.UIFlexAlignment.SpaceEvenly, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8) + }, + + ui.padding { + y = UDim.new(0, 4) + }, + + ui.typography { + size = UDim2.new(0, 100, 0, 18), + automaticsize = Enum.AutomaticSize.Y, + text = key, + code = true, + wrapped = true, + truncate = Enum.TextTruncate.SplitWord, + xalignment = Enum.TextXAlignment.Left, + }, + + ui.typography { + size = UDim2.fromOffset(0, 18), + automaticsize = Enum.AutomaticSize.Y, + + yalignment = Enum.TextYAlignment.Top, + xalignment = Enum.TextXAlignment.Left, + text = value, + wrapped = true, + truncate = Enum.TextTruncate.AtEnd, + code = true, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + }, + + }, + + show(function() + return props.changes()[key] ~= nil + end, function() + return ui.typography { + text = function() + local old = value() + local change = props.changes()[key] + + return if old == nil then "(added)" + elseif is_removed(change) then "(removed)" + else "(changed)" + end, + disabled = true, + textsize = 14, + } + end), + + create "UISizeConstraint" { + MaxSize = Vector2.new(math.huge, 300) + } + } + end), + }, + + ui.typography {text = "Tags"}, + + ui.container { + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + + create "UIListLayout" { + SortOrder = Enum.SortOrder.Name, + }, + + indexes(tags, function(value, key) + local function did_change() + return changes()[key] ~= nil + end + + return ui.button { + { Name = if not string.match(key, "^%a") then "zzzz" .. key else key }, + size = UDim2.new(1, 0, 0, 24), + + text = "", + + corner = false, + + activated = function() + if changes()[key] == "tag" then + changes()[key] = nil + props.components()[key] = nil + + -- notify about the change + changes(changes()) + props.components(props.components()) + elseif changes()[key] then + changes()[key] = nil + + -- notify about the change + changes(changes()) + else + changes()[key] = "nil" + + -- notify about the change + changes(changes()) + end + end, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalFlex = Enum.UIFlexAlignment.SpaceEvenly, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8) + }, + + ui.padding { + y = UDim.new(0, 4) + }, + + ui.typography { + size = UDim2.fromScale(0, 1), + text = key, + code = true, + wrapped = true, + truncate = Enum.TextTruncate.SplitWord, + xalignment = Enum.TextXAlignment.Left, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + } + }, + + show(did_change, function() + return ui.typography { + text = function() + local old = value() + local change = changes()[key] + + return if old == nil then "(added)" + elseif is_removed(change) then "(removed)" + else "(changed)" + end, + disabled = true, + textsize = 14, + } + end) + } + end), + } + + }, + + ui.row { + + justifycontent = Enum.UIFlexAlignment.Fill, + + ui.button { + size = UDim2.fromOffset(100, 30), + text = "Delete Id", + + activated = props.delete + }, + + ui.button { + size = UDim2.fromOffset(200, 30), + text = "Add Component", + + activated = function() + adding(true) + end + }, + + + } + + } + + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/home/init.luau b/modules/Jabby/client/apps/home/init.luau new file mode 100644 index 0000000..3b3b146 --- /dev/null +++ b/modules/Jabby/client/apps/home/init.luau @@ -0,0 +1,36 @@ +local RunService = game:GetService("RunService") + +local vide = require(script.Parent.Parent.Parent.vide) +local loop = require(script.Parent.Parent.Parent.modules.loop) +local widget = require(script.widget) + +local cleanup = vide.cleanup + +local home = { + class_name = "app" :: "app", + name = "Home" +} + +function home.mount(_: nil, destroy: () -> ()) + + local servers = vide.source {} :: any + local app_loop = loop ( + "app-client-home", + servers, + + {i = 1}, + script.systems.get_core_data + ) + + cleanup( + RunService.Heartbeat:Connect(app_loop) + ) + + return widget { + servers = servers, + destroy = destroy + } + +end + +return home \ No newline at end of file diff --git a/modules/Jabby/client/apps/home/systems/get_core_data.luau b/modules/Jabby/client/apps/home/systems/get_core_data.luau new file mode 100644 index 0000000..7bceb66 --- /dev/null +++ b/modules/Jabby/client/apps/home/systems/get_core_data.luau @@ -0,0 +1,93 @@ +local Players = game:GetService("Players") + +local vide = require(script.Parent.Parent.Parent.Parent.Parent.vide) +local queue = require(script.Parent.Parent.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.Parent.Parent.modules.remotes) +local reverse_connector = require(script.Parent.Parent.Parent.Parent.Parent.modules.reverse_connector) + +return function(data) + + for _, player in Players:GetPlayers() do + remotes.ping:fire({ + host = player, + }) + end + remotes.ping:fire({ + host = "server" + }) + + local servers_responding = queue(remotes.new_server_registered) + local server_update = queue(remotes.update_server_data) + local player_removing = queue(Players.PlayerRemoving) + + local n = 0 + local servers = data :: any + local map_to_idx = { + server = {} + } + + return function() + + for connector in servers_responding:iter() do + local outgoing = reverse_connector(connector) + remotes.bind_to_server_core:fire(outgoing) + end + + for player in player_removing:iter() do + local indexes = map_to_idx[player] + + if not indexes then continue end + + for _, idx in indexes do + servers()[idx] = nil + end + servers(servers()) + end + + for connector, packet in server_update:iter() do + local outgoing = reverse_connector(connector) + + map_to_idx[outgoing.host] = map_to_idx[outgoing.host] or {} + local idx = map_to_idx[outgoing.host][outgoing.to_vm] + + if not idx then + -- print("new server") + idx = n + 1; n += 1 + map_to_idx[outgoing.host][outgoing.to_vm] = idx + servers()[idx] = { + host = outgoing.host, + vm = outgoing.to_vm, + + schedulers = vide.source {}, + worlds = vide.source {} + } + servers(servers()) + -- print("set worlds") + -- print(servers()) + end + + local server = servers()[idx] + local schedulers = server.schedulers() + local worlds = server.worlds() + + table.clear(schedulers) + table.clear(worlds) + + for index, data in packet.schedulers do + local at = schedulers[index] + if at and at.name == data.name and at.id == data.id then continue end + schedulers[index] = data + end + + for index, data in packet.worlds do + local at = worlds[index] + if at and at.name == data.name and at.id == data.id then continue end + worlds[index] = data + end + + server.schedulers(schedulers) + server.worlds(worlds) + + end + end +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/home/widget.luau b/modules/Jabby/client/apps/home/widget.luau new file mode 100644 index 0000000..4b0fe93 --- /dev/null +++ b/modules/Jabby/client/apps/home/widget.luau @@ -0,0 +1,183 @@ +local Players = game:GetService("Players") + +local ui = require(script.Parent.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local spawn_app = require(script.Parent.Parent.Parent.spawn_app) +local overview_scheduler = require(script.Parent.Parent.overview_scheduler) +local registry = require(script.Parent.Parent.registry) + +local create = vide.create +local derive = vide.derive +local source = vide.source +local values = vide.values +local show = vide.show + +type props = { + + servers: () -> { + { + host: "server" | Player, + vm: number, + + schedulers: () -> { + {id: number, name: string} + }, + worlds: () -> { + {id: number, name: string} + } + } + }, + + destroy: () -> () + +} + +return function(props: props) + + local selected = source(Players.LocalPlayer) + + local hosts = derive(function() + local hosts = {} + + for _, server in props.servers() do + local host = server.host + hosts[host] = hosts[host] or {} + table.insert(hosts[host], server) + end + + hosts["all"] = props.servers() + + return hosts + end) + + local options = derive(function() + local options = {} + + for host, servers in hosts() do + options[host] = if type(host) == "string" then host else `@{host.Name}` + end + + options[Players.LocalPlayer] = "localplayer" + options["all"] = "all" + + return options + end) + + local function objects() + return hosts()[selected()] or {} :: never + end + + local function is_empty() + local no_objects = next(objects()) == nil + if no_objects then return "No objects found. You may not have permissions to use this." end + -- for _, object in objects() do + -- if #object.worlds() == 0 then return "No worlds found. Did you forget to set updated?" end + -- if #object.schedulers() == 0 then return "No schedulers found. Did you forget to set updated?" end + -- end + return false + end + + return ui.widget { + title = "Home", + min_size = Vector2.new(230, 200), + bind_to_close = props.destroy, + + ui.container { + + create "UIListLayout" { + Padding = UDim.new(0, 2), + VerticalFlex = Enum.UIFlexAlignment.SpaceEvenly, + HorizontalFlex = Enum.UIFlexAlignment.Fill + }, + + ui.select { + size = UDim2.new(1, 0, 0, 32), + options = options :: any, + selected = selected :: any, + update_selected = selected + }, + + create "ScrollingFrame" { + -- Size = UDim2.fromScale(1, 1), + CanvasSize = UDim2.new(), + AutomaticCanvasSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + ScrollBarThickness = 6, + HorizontalScrollBarInset = Enum.ScrollBarInset.Always, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + }, + + ui.padding { + x = UDim.new(0, 1), + right = UDim.new(0, 8) + }, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalFlex = Enum.UIFlexAlignment.Fill, + Padding = UDim.new(0, 8), + Wraps = true + }, + + show(is_empty, function() + return ui.typography { + text = is_empty(), + xalignment = Enum.TextXAlignment.Left, + wrapped = true + } + end), + + values(objects, function(value, key) + + return ui.pane{ + name = "", + size = UDim2.fromOffset(200, 0), + automaticsize = Enum.AutomaticSize.Y, + + create "UIListLayout" { + Padding = UDim.new(0, 8) + }, + + ui.typography { + text = `host: {value.host}\tvm id: {value.vm}`, + wrapped = true + }, + + values(value.worlds, function(world) + return ui.button { + size = UDim2.new(1, 0, 0, 30), + text = `World: {world.name}`, + + activated = function() + spawn_app.spawn_app(registry, { + host = value.host, + vm = value.vm, + id = world.id + }) + end + } + end), + + values(value.schedulers, function(scheduler) + return ui.button { + size = UDim2.new(1, 0, 0, 30), + text = `Scheduler: {scheduler.name}`, + + activated = function() + spawn_app.spawn_app(overview_scheduler, { + host = value.host, + vm = value.vm, + id = scheduler.id + }) + end + } + end) + } + end) + } + } + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/overview_scheduler/init.luau b/modules/Jabby/client/apps/overview_scheduler/init.luau new file mode 100644 index 0000000..ca9c0f3 --- /dev/null +++ b/modules/Jabby/client/apps/overview_scheduler/init.luau @@ -0,0 +1,68 @@ +local RunService = game:GetService("RunService") + +local vide = require(script.Parent.Parent.Parent.vide) +local loop = require(script.Parent.Parent.Parent.modules.loop) +local remotes = require(script.Parent.Parent.Parent.modules.remotes) +local widget = require(script.widget) + +local source = vide.source +local cleanup = vide.cleanup + +local overview_scheduler = { + class_name = "app" :: "app", + name = "Scheduler" +} + +type props = { + host: Player | "server", + vm: number, + id: number +} + +function overview_scheduler.mount(props: props, destroy: () -> ()) + + local system_data = source {} + local system_frames = source {} + local system_ids = source {} + + local app_loop = loop ( + "app-client-scheduler", + { + host = props.host, + vm = props.vm, + id = props.id, + + system_ids = system_ids, + system_data = system_data, + system_frames = system_frames, + }, + + {i = 1}, + script.systems.get_scheduler_data + ) + + cleanup( + RunService.Heartbeat:Connect(app_loop) + ) + + return widget { + host = props.host, + vm = props.vm, + id = props.id, + + system_ids = system_ids, + system_data = system_data, + system_frames = system_frames, + pause_system = function(system: number) + remotes.scheduler_system_pause:fire({ + host = props.host, + to_vm = props.vm + }, props.id, system, not system_data()[system].paused) + end, + + destroy = destroy + } + +end + +return overview_scheduler \ No newline at end of file diff --git a/modules/Jabby/client/apps/overview_scheduler/stack_bar.luau b/modules/Jabby/client/apps/overview_scheduler/stack_bar.luau new file mode 100644 index 0000000..2d865f1 --- /dev/null +++ b/modules/Jabby/client/apps/overview_scheduler/stack_bar.luau @@ -0,0 +1,46 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) + +local create = vide.create +local indexes = vide.indexes +local derive = vide.derive + +type stack_bar = { + values: () -> {{value: number, color: Color3}}, + selected: (number) -> () +} + +return function(props: stack_bar) + + local total = derive(function() + local total = 0 + for _, value in props.values() do + total += value.value + end + return total + end) + + return create "Frame" { + Name = "Graph", + Size = UDim2.new(1, 0, 0, 32), + + indexes(props.values, function(value, index) + return create "Frame" { + + Size = function() + return UDim2.fromScale(value().value / total(), 1) + end, + + BackgroundColor3 = function() return value().color end, + + MouseEnter = function() + props.selected(index) + end, + } + end), + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + }, + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/overview_scheduler/systems/get_scheduler_data.luau b/modules/Jabby/client/apps/overview_scheduler/systems/get_scheduler_data.luau new file mode 100644 index 0000000..501693d --- /dev/null +++ b/modules/Jabby/client/apps/overview_scheduler/systems/get_scheduler_data.luau @@ -0,0 +1,98 @@ +local vide = require(script.Parent.Parent.Parent.Parent.Parent.vide) +local queue = require(script.Parent.Parent.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.Parent.Parent.modules.remotes) +local types = require(script.Parent.Parent.Parent.Parent.Parent.modules.types) + +local batch = vide.batch +local cleanup = vide.cleanup + +type SystemId = types.SystemId +type SystemData = types.SystemData +type SystemFrame = types.SystemFrame + +type context = { + host: Player | "server", + vm: number, + id: number, + + system_ids: vide.Source<{[SystemId]: true}>, + system_data: vide.Source<{[SystemId]: types.SystemData}>, + system_frames: vide.Source<{[SystemId]: {types.SystemFrame}}>, +} + +local MAX_BUFFER_SIZE = 50 + +return function(context: context) + local outgoing = { + host = context.host, + to_vm = context.vm + } + + remotes.request_scheduler:fire(outgoing, context.id) + + local scheduler_static_data_updated = queue(remotes.scheduler_system_static_update) + local scheduler_frame_data_updated = queue(remotes.scheduler_system_update) + + cleanup(function() + remotes.disconnect_scheduler:fire(outgoing, context.id) + end) + + return function() + + batch(function() + for incoming, scheduler, id, new_data in scheduler_static_data_updated:iter() do + if incoming.host ~= context.host then continue end + if incoming.from_vm ~= context.vm then continue end + if scheduler ~= context.id then continue end + + if new_data == nil then + context.system_ids()[id] = nil + context.system_data()[id] = nil + context.system_frames()[id] = nil + + context.system_ids(context.system_ids()) + context.system_data(context.system_data()) + context.system_frames(context.system_frames()) + else + context.system_ids()[id] = true + context.system_data()[id] = new_data + context.system_frames()[id] = context.system_frames()[id] or {} + + context.system_ids(context.system_ids()) + context.system_data(context.system_data()) + context.system_frames(context.system_frames()) + end + + end + + for incoming, scheduler, id, f, s in scheduler_frame_data_updated:iter() do + if incoming.host ~= context.host then continue end + if incoming.from_vm ~= context.vm then continue end + if scheduler ~= context.id then continue end + if context.system_frames()[id] == nil then continue end + + -- look where to append the frame to + local frames = context.system_frames()[id] + local f_data = {i = f, s = s} + local added = false + + --- since it's unreliable we have to constantly check if we arent out of order + for i, frame in frames do + if frame.i == f then frames[i] = f_data; continue end + if frame.i > f then continue end + table.insert(frames, i, f_data) + table.remove(frames, MAX_BUFFER_SIZE + 1) + added = true + break + end + + if #frames <= MAX_BUFFER_SIZE and added == false then + table.insert(frames, f_data) + end + + context.system_frames(context.system_frames()) + end + end) + + end +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/overview_scheduler/widget.luau b/modules/Jabby/client/apps/overview_scheduler/widget.luau new file mode 100644 index 0000000..c19e5ff --- /dev/null +++ b/modules/Jabby/client/apps/overview_scheduler/widget.luau @@ -0,0 +1,412 @@ +local ui = require(script.Parent.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local convert_units = require(script.Parent.Parent.Parent.Parent.modules.convert_units) +local types = require(script.Parent.Parent.Parent.Parent.modules.types) +local spawn_app = require(script.Parent.Parent.Parent.spawn_app) +local system_widget = require(script.Parent.Parent.system) +local stack_bar = require(script.Parent.stack_bar) + +local create = vide.create +local indexes = vide.indexes +local values = vide.values +local changed = vide.changed +local source = vide.source +local derive = vide.derive + +type SystemId = number + +type props = { + host: Player | "server", + vm: number, + id: number, + + system_ids: () -> {[SystemId]: true}, + system_data: () -> {[SystemId]: types.SystemData}, + system_frames: () -> {[SystemId]: {types.SystemFrame}}, + + pause_system: (SystemId) -> (), + destroy: () -> () + +} + +local function color(n: number) + return Color3.fromHSV((0.15 * (n-1)) % 1, 1, 1) +end + +local sort_by_options = { + "Name", + "Id", + "Frame Time" +} + +return function(props: props) + + local selected = source(0) + local systems_query = source("") + + local sort_by = source(2) + + local max_frametime = derive(function() + local max = 0 + for _, frames in props.system_frames() do + local sum = 0 + + for _, frame in frames do + sum += frame.s + end + + max = math.max(max, sum / #frames) + end + return max + end) + + local map_phases_to_systems = derive(function() + local phases: {[false | string]: {number}} = {[false] = {}} + for id, data in props.system_data() do + if phases[data.phase or false] == nil then phases[data.phase or false] = {} end + table.insert(phases[data.phase or false], id) + end + return phases + end) + + local function system(id: number) + local gui_state = source(Enum.GuiState.Idle) + + local function frame_time() + local sum = 0 + local frames = props.system_frames()[id] + + for _, frame in frames do + sum += frame.s + end + + return sum / #frames + end + + local b = create "ImageButton" { + Name = function() + return props.system_data()[id].name + end, + Size = UDim2.new(1, 0, 0, 32), + + LayoutOrder = function() + return if sort_by() == 3 then 1e9 - frame_time() * 1e8 else id + end, + + BackgroundColor3 = function() + return if gui_state() == Enum.GuiState.Press then + ui.theme.bg[-1]() + elseif gui_state() == Enum.GuiState.Hover then + ui.theme.bg[6]() + else + ui.theme.bg[3]() + end, + + Visible = function() + return not not string.match(props.system_data()[id].name, systems_query()) + end, + + changed("GuiState", gui_state), + + Activated = function() + spawn_app.spawn_app(system_widget, { + host = props.host, + vm = props.vm, + + scheduler = props.id, + system = id, + name = props.system_data()[id].name + }) + end, + + MouseButton2Click = function() + props.pause_system(id) + end, + + -- create a frame that ignores all rules! + create "Folder" { + create "Frame" { + Position = UDim2.new(0, 0, 1, 4), + AnchorPoint = Vector2.new(0, 1), + Size = function() + return UDim2.new(frame_time() / max_frametime(), 0, 0, 1) + end, + + BackgroundColor3 = ui.theme.fg_on_bg_high[0] + + } + }, + + create "UIStroke" { + Color = ui.theme.bg[-3] + }, + + create "UICorner" { + CornerRadius = UDim.new(0, 4) + }, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + HorizontalFlex = Enum.UIFlexAlignment.SpaceEvenly, + Padding = UDim.new(0, 8) + }, + + ui.padding { + x = UDim.new(0, 8) + }, + + create "Frame" { + Size = UDim2.fromOffset(16, 16), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundColor3 = color(id), + + create "UICorner" { + CornerRadius = UDim.new(1, 0) + }, + }, + + ui.typography { + automaticsize = Enum.AutomaticSize.None, + text = function() + return props.system_data()[id].name + end, + + truncate = Enum.TextTruncate.SplitWord, + xalignment = Enum.TextXAlignment.Left, + disabled = function() + return props.system_data()[id].paused + end, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill, + GrowRatio = 1, + ShrinkRatio = 1 + } + }, + + ui.typography { + automaticsize = Enum.AutomaticSize.XY, + text = function() + local sum = 0 + local frames = props.system_frames()[id] + + for _, frame in frames do + sum += frame.s + end + + return `{convert_units("s", sum / #frames)}` + end, + + xalignment = Enum.TextXAlignment.Right, + + disabled = true, + + }, + + } + return b + end + + return ui.widget { + title = "Scheduler", + subtitle = `host: {props.host} vm: {props.vm} id: {props.id}`, + + min_size = Vector2.new(200, 300), + + bind_to_close = props.destroy, + + create "Frame" { + Name = "Elements", + Size = UDim2.fromScale(1, 1), + AutomaticSize = Enum.AutomaticSize.Y, + + BackgroundTransparency = 1, + + create "UIListLayout" { + VerticalAlignment = Enum.VerticalAlignment.Bottom, + FillDirection = Enum.FillDirection.Vertical, + VerticalFlex = Enum.UIFlexAlignment.SpaceBetween, + Padding = UDim.new(0, 8) + }, + + create "Frame" { + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + + BackgroundTransparency = 1, + + -- create "UIFlexItem" { + -- FlexMode = Enum.UIFlexMode.Custom, + -- GrowRatio = 0, + -- ShrinkRatio = 0 + -- }, + + ui.pane { + name = "Overview", + size = UDim2.fromScale(1, 0), + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Vertical + }, + + ui.typography { + text = function() + local run_time = 0 + for id, frames in props.system_frames() do + if props.system_data()[id].paused then continue end + local sum = 0 + + for _, frame in frames do + sum += frame.s + end + + run_time += sum / #frames + end + + return `Run time: {convert_units("s", run_time)}` + end + }, + + stack_bar { + values = function() + local v = {} + + local system_ids = props.system_ids() + local system_frames = props.system_frames() + + for i = 1, table.maxn(system_ids) do + if system_ids[i] == nil then continue end + if props.system_data()[i].paused then continue end + + local sum = 0 + local frames = system_frames[i] + + for _, frame in frames do + sum += frame.s + end + + table.insert(v, {value = sum / #frames, color = color(i)}) + end + + return v + end, + selected = selected + }, + + ui.row { + justifycontent = Enum.UIFlexAlignment.Fill, + ui.button { + text = "Pause all", + + activated = function() + for system, data in props.system_data() do + if data.paused then continue end + props.pause_system(system) + end + end + }, + + ui.button { + text = "Resume all", + + activated = function() + for system, data in props.system_data() do + if not data.paused then continue end + props.pause_system(system) + end + end + } + } + }, + + }, + + ui.select { + size = UDim2.new(1, 0, 0, 30), + + options = sort_by_options, + selected = sort_by, + update_selected = function(new) + -- print(new) + sort_by(new) + end + }, + + ui.textfield { + size = UDim2.new(1, 0, 0, 36), + + placeholder = "System Match", + + oninput = systems_query, + }, + + create "ScrollingFrame" { + Name = "Systems", + + Size = UDim2.fromScale(1, 0), + CanvasSize = UDim2.new(), + AutomaticCanvasSize = Enum.AutomaticSize.Y, + + BackgroundTransparency = 1, + + ScrollBarThickness = 6, + VerticalScrollBarInset = Enum.ScrollBarInset.Always, + ScrollBarImageColor3 = ui.theme.fg_on_bg_low[3], + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + }, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Vertical, + Padding = UDim.new(0, 8), + + SortOrder = function() + return if sort_by() == 1 then Enum.SortOrder.Name else Enum.SortOrder.LayoutOrder + end + }, + + ui.padding { + y = UDim.new(0, 1), + x = UDim.new(0, 1) + }, + + values(function() + return map_phases_to_systems()[false] + end, system), + + indexes(map_phases_to_systems, function(systems, phase) + if phase == false then return {} end + local expanded = source(true) + -- print(systems()) + return ui.accordion { + expanded = expanded, + set_expanded = expanded, + text = phase, + + ui.container { + Size = UDim2.fromScale(1, 0), + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Vertical, + Padding = UDim.new(0, 8), + + SortOrder = function() + return if sort_by() == 1 then Enum.SortOrder.Name else Enum.SortOrder.LayoutOrder + end + }, + + values(systems, system) + } + } + end), + + } + + } + + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/registry/init.luau b/modules/Jabby/client/apps/registry/init.luau new file mode 100644 index 0000000..5d4b3f3 --- /dev/null +++ b/modules/Jabby/client/apps/registry/init.luau @@ -0,0 +1,154 @@ +local ContextActionService = game:GetService("ContextActionService") +local RunService = game:GetService("RunService") + +local vide = require(script.Parent.Parent.Parent.vide) +local loop = require(script.Parent.Parent.Parent.modules.loop) +local spawn_app = require(script.Parent.Parent.spawn_app) +local entity_widget = require(script.Parent.entity) +local widget = require(script.widget) + +local source = vide.source +local effect = vide.effect +local cleanup = vide.cleanup + +local overview_query = { + class_name = "app" :: "app", + name = "Query" +} + +type props = { + host: Player | "server", + vm: number, + id: number +} + +function overview_query.mount(props: props, destroy: () -> ()) + + -- the entity id + local current_entity = source(nil) + -- enables picking + local enable_pick = source(false) + -- the entity data as a string + local entity_hovering_over = source() + -- the part the player is hovering over + local hovering_over = source() + + local validate_query = source("") + local ok = source(false) + local msg = source("") + + local query = source("") + local primary_entity = source() + local columns = source({}) + local from = source(1) + local upto = source(25) + local total_entities = source(0) + + local paused = source(false) + local refresh = source(false) + + -- check if the query and columns are properly + local app_loop = loop ( + "app-client-registry", + { + host = props.host, + vm = props.vm, + id = props.id, + + enable_pick = enable_pick, + entity_hovering_over = entity_hovering_over, + hovering_over = hovering_over, + set_entity = current_entity, + + columns = columns, + query = query, + primary_entity = primary_entity, + paused = paused, + refresh = refresh, + + total_entities = total_entities, + + from = from, + upto = upto, + + validate_query = validate_query, + ok = ok, + msg = msg + }, + + {i = 1}, + script.systems.validate_query, + script.systems.obtain_query_data, + script.systems.send_workspace_entity, + script.systems.highlight_workspace_entity + ) + + cleanup(RunService.Heartbeat:Connect(app_loop)) + + local function open_entity_widget(_, state: Enum.UserInputState) + local entity = current_entity() + + if state ~= Enum.UserInputState.Begin then return end + if entity == nil then return end + + enable_pick(false) + entity_hovering_over(nil) + hovering_over(nil) + + spawn_app.spawn_app(entity_widget, { + host = props.host, + vm = props.vm, + id = props.id, + entity = entity + }) + end + + effect(function() + local picking = enable_pick() + local key = `select entity:{props.host} {props.vm} {props.id}` + + if picking then + ContextActionService:BindAction(key, open_entity_widget, false, Enum.UserInputType.MouseButton1) + end + + cleanup(function() + ContextActionService:UnbindAction(key) + end) + + end) + + return widget { + host = props.host, + vm = props.vm, + id = props.id, + + validate_query = validate_query, + + update_system_query = query, + current_query = query, + total_rows_per_page = source(25), + set_rows_per_page = source(25), + + primary_entity = primary_entity, + + from = from, + upto = upto, + total_entities = total_entities, + paused = paused, + refresh = refresh, + + enable_pick = enable_pick, + entity_hovering_over = entity_hovering_over, + hovering_over = hovering_over, + + ok = ok, + msg = msg, + + columns = columns, + + destroy = destroy + } + +end + +return overview_query \ No newline at end of file diff --git a/modules/Jabby/client/apps/registry/systems/highlight_workspace_entity.luau b/modules/Jabby/client/apps/registry/systems/highlight_workspace_entity.luau new file mode 100644 index 0000000..09bd7c8 --- /dev/null +++ b/modules/Jabby/client/apps/registry/systems/highlight_workspace_entity.luau @@ -0,0 +1,34 @@ +local queue = require(script.Parent.Parent.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.Parent.Parent.modules.remotes) + +type Context = { + host: Player | "server", + vm: number, + id: number, + + enable_pick: () -> boolean, + entity_hovering_over: (string) -> (), + set_entity: (number) -> (), + hovering_over: (BasePart) -> () + +} + +return function(context: Context) + + local send_mouse_entity = queue(remotes.send_mouse_entity) + + return function() + + for incoming, id, to_highlight, entity, components in send_mouse_entity:iter() do + if incoming.host ~= context.host then continue end + if incoming.from_vm ~= context.vm then continue end + if id ~= context.id then continue end + if context.enable_pick() == false then continue end + context.hovering_over(to_highlight) + context.entity_hovering_over(components) + context.set_entity(entity) + end + + end + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/registry/systems/obtain_query_data.luau b/modules/Jabby/client/apps/registry/systems/obtain_query_data.luau new file mode 100644 index 0000000..989c97a --- /dev/null +++ b/modules/Jabby/client/apps/registry/systems/obtain_query_data.luau @@ -0,0 +1,128 @@ +local vide = require(script.Parent.Parent.Parent.Parent.Parent.vide) +local queue = require(script.Parent.Parent.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.Parent.Parent.modules.remotes) + +local effect = vide.effect +local batch = vide.batch +local cleanup = vide.cleanup + +type Context = { + host: Player | "server", + vm: number, + id: number, + + columns: vide.Source<{ { any } }>, + query: () -> string, + + paused: () -> boolean, + refresh: vide.Source, + + total_entities: (number) -> (), + + from: () -> number, + upto: () -> number, + +} + +local function generate_random_query_id() + return math.random(2 ^ 31 - 1) +end + +return function(context: Context) + + local query_changed = false + local page_changed = false + + effect(function() + if #context.query() > 0 then + query_changed = true + end + end) + + effect(function() + context.from() + context.upto() + page_changed = true + end) + + local current_query_id = -1 + local query_last_frame = 0 + local update_query_result = queue(remotes.update_query_result) + local count_updated = queue(remotes.count_total_entities) + + local columns = context.columns + local outgoing = { + host = context.host, + to_vm = context.vm + } + + cleanup(function() + remotes.disconnect_query:fire(outgoing, current_query_id) + end) + + local should_refresh = false + effect(function() + if context.refresh() ~= true then return end + context.refresh(false) + should_refresh = true + end) + + local paused_state = context.paused() + local paused_updated = false + effect(function() + paused_updated = true + paused_state = context.paused() + end) + + return function() + + if query_changed then + columns({}) + remotes.disconnect_query:fire(outgoing, current_query_id) + current_query_id = generate_random_query_id() + -- print("requesting new query", current_query_id) + remotes.request_query:fire(outgoing, context.id, current_query_id, context.query()) + remotes.advance_query_page:fire(outgoing, current_query_id, context.from(), context.upto()) + query_last_frame = 0 + query_changed = false + + remotes.pause_query:fire(outgoing, current_query_id, paused_state) + end + + if page_changed then + remotes.advance_query_page:fire(outgoing, current_query_id, context.from(), context.upto()) + page_changed = false + end + + for incoming, query, value in count_updated:iter() do + if query ~= current_query_id then continue end + context.total_entities(value) + end + + if paused_updated then + remotes.pause_query:fire(outgoing, current_query_id, paused_state) + paused_updated = false + end + + if should_refresh then + remotes.refresh_results:fire(outgoing, current_query_id) + should_refresh = false + end + + batch(function() + for incoming, query, frame, column, row, value in update_query_result:iter() do + if query ~= current_query_id then continue end + if frame < query_last_frame - 10 then continue end + query_last_frame = math.max(query_last_frame, frame) + + if columns()[column] == nil then + columns()[column] = {} + end + + columns()[column][row] = value + columns(columns()) + end + end) + + end +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/registry/systems/send_workspace_entity.luau b/modules/Jabby/client/apps/registry/systems/send_workspace_entity.luau new file mode 100644 index 0000000..146f5a7 --- /dev/null +++ b/modules/Jabby/client/apps/registry/systems/send_workspace_entity.luau @@ -0,0 +1,33 @@ +local UserInputService = game:GetService("UserInputService") + +local remotes = require(script.Parent.Parent.Parent.Parent.Parent.modules.remotes) + +type Context = { + host: Player | "server", + vm: number, + id: number, + + enable_pick: () -> boolean, + +} + +return function(context: Context) + + if context.enable_pick() == false then return end + + local location = UserInputService:GetMouseLocation() + local camera = workspace.CurrentCamera + + local ray = camera:ViewportPointToRay(location.X, location.Y) + + remotes.send_mouse_pointer:fire( + { + host = context.host, + to_vm = context.vm + }, + context.id, + ray.Origin, + ray.Direction + ) + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/registry/systems/validate_query.luau b/modules/Jabby/client/apps/registry/systems/validate_query.luau new file mode 100644 index 0000000..55d8da9 --- /dev/null +++ b/modules/Jabby/client/apps/registry/systems/validate_query.luau @@ -0,0 +1,98 @@ +local Players = game:GetService("Players") + +local jecs = require(script.Parent.Parent.Parent.Parent.Parent.jecs) +local vide = require(script.Parent.Parent.Parent.Parent.Parent.vide) +local queue = require(script.Parent.Parent.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.Parent.Parent.modules.remotes) + +local effect = vide.effect + +type Context = { + host: Player | "server", + vm: number, + id: number, + + validate_query: () -> string, + + msg: (string) -> (), + ok: (boolean) -> (), + primary_entity: (any?) -> () + +} + +return function(context: Context) + + local query_changed = false + + effect(function() + context.validate_query() + + query_changed = true + end) + + local n = 0 + local already_validated = false + local MIN_DELAY_UNTIL_VALIDATE = 0 + + local validate_result = queue(remotes.validate_result) + + if context.host == Players.LocalPlayer then + MIN_DELAY_UNTIL_VALIDATE = 0.3 + elseif context.host == "server" then + MIN_DELAY_UNTIL_VALIDATE = 0.5 + else + MIN_DELAY_UNTIL_VALIDATE = 0.5 + end + + return function(dt) + if query_changed then + n = 0 + already_validated = false + query_changed = false + + context.ok(false) + context.msg("") + end + + for incoming, world, query, terms, ok, msg in validate_result:iter() do + if incoming.host ~= context.host then continue end + if incoming.from_vm ~= context.vm then continue end + if world ~= context.id then continue end + if query ~= context.validate_query() then continue end + + context.ok(ok) + context.msg(msg or "") + context.primary_entity(nil) + + if not terms then continue end + if #terms.include + #terms.exclude + #terms.with > 1 then continue end + local first = terms.include[1] + + if first == nil then continue end + if jecs.IS_PAIR(first) then continue end + context.primary_entity(terms.include[1]) + end + + n += dt + if n < MIN_DELAY_UNTIL_VALIDATE then return end + if already_validated then return end + + if context.validate_query() == "" then + context.ok(false) + context.msg("empty query") + return + end + + already_validated = true + + remotes.validate_query:fire( + { + host = context.host, + to_vm = context.vm + }, + context.id, + context.validate_query() + ) + + end +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/registry/widget.luau b/modules/Jabby/client/apps/registry/widget.luau new file mode 100644 index 0000000..76f3882 --- /dev/null +++ b/modules/Jabby/client/apps/registry/widget.luau @@ -0,0 +1,503 @@ +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") + +local ui = require(script.Parent.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local tooltip = require(script.Parent.Parent.Parent.components.tooltip) +local spawn_app = require(script.Parent.Parent.Parent.spawn_app) +local entity = require(script.Parent.Parent.entity) + +local create = vide.create +local effect = vide.effect +local source = vide.source +local show = vide.show + +type SystemId = number + +type props = { + host: Player | "server", + vm: number, + id: number, + + validate_query: (string) -> (), + ok: () -> boolean, + msg: () -> string, + + paused: vide.Source, + refresh: (boolean) -> (), + + from: vide.Source, + upto: vide.Source, + + primary_entity: () -> number?, + + update_system_query: (query: string) -> (), + current_query: () -> string, + total_entities: () -> number, + + enable_pick: vide.Source, + entity_hovering_over: () -> string, + hovering_over: () -> BasePart, + + columns: () -> {{any}}, + + destroy: () -> () +} + +local mouse_location = source(Vector2.zero) + +RunService.PreRender:Connect(function() + mouse_location(UserInputService:GetMouseLocation()) +end) + +return function(props: props) + + local page_input = source("1") + local rows_input = source("25") + + local page = source(1) + local rows = source(20) + + effect(function() + page_input(tostring(page())) + end) + + effect(function() + rows_input(tostring(rows())) + end) + + effect(function() + local page = page() + local rows_per_page = rows() + + local from = (page - 1) * rows_per_page + 1 + local upto = from + rows_per_page - 1 + + props.from(from) + props.upto(upto) + end) + + local row_template = { + Size = UDim2.new(0, 0, 0, 26), + AutomaticSize = Enum.AutomaticSize.X + } :: any + + return ui.widget { + title = "Querying", + subtitle = `host: {props.host} vm: {props.vm} id: {props.id}`, + + min_size = Vector2.new(300, 300), + + bind_to_close = props.destroy, + + create "Frame" { + + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + + tooltip { + transparency = 0.3, + visible = function() + return props.entity_hovering_over() and #props.entity_hovering_over() > 0 or false + end, + + ui.typography { + automaticsize = Enum.AutomaticSize.XY, + + text = function() + return props.entity_hovering_over() or "" + end, + xalignment = Enum.TextXAlignment.Left, + wrapped = true, + code = true, + + { RichText = true }, + + create "UIStroke" { + Thickness = 1, + Color = ui.theme.bg[-5] + } + } + }, + + create "Highlight" { + DepthMode = Enum.HighlightDepthMode.AlwaysOnTop, + OutlineColor = Color3.new(1, 1, 1), + FillColor = ui.theme.acc[0], + FillTransparency = 0.5, + + Adornee = props.hovering_over + }, + + create "UIListLayout" { + VerticalFlex = Enum.UIFlexAlignment.SpaceAround, + Padding = UDim.new(0, 8) + }, + + create "Frame" { + Name = "Query + Pick", + Size = UDim2.new(1, 0, 0, 30), + + BackgroundTransparency = 1, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalFlex = Enum.UIFlexAlignment.SpaceAround, + VerticalFlex = Enum.UIFlexAlignment.SpaceAround, + Padding = UDim.new(0, 8) + }, + + create "Frame" { + + Name = "Query", + + Size = UDim2.fromScale(0, 1), + BackgroundTransparency = 1, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + }, + + ui.textfield { + size = UDim2.new(1, 0, 0, 30), + placeholder = "Query", + + code = true, + + oninput = function(text) + props.validate_query(text) + end, + + enter = function(text) + props.update_system_query(text) + end + }, + + }, + + vide.show(function() + return props.primary_entity() + end, function() + return ui.button { + size = UDim2.fromOffset(30, 30), + + text = "", + + activated = function() + spawn_app.spawn_app(entity, { + host = props.host, + vm = props.vm, + id = props.id, + entity = props.primary_entity() + }) + end, + + create "ImageLabel" { + Size = UDim2.fromOffset(24, 24), + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundTransparency = 1, + + ImageColor3 = ui.theme.fg_on_bg_high[3], + + Image = "rbxassetid://10723415903" + }, + + } + end), + + ui.button { + size = UDim2.fromOffset(30, 30), + + text = "", + + accent = props.enable_pick, + + activated = function() + props.enable_pick(not props.enable_pick()) + end, + + create "ImageLabel" { + Size = UDim2.fromOffset(24, 24), + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundTransparency = 1, + + ImageColor3 = ui.theme.fg_on_bg_high[3], + + Image = "rbxassetid://10734898355" + }, + + }, + + }, + + create "Frame" { + Size = UDim2.new(1, 0, 0, 24), + + BackgroundTransparency = 1, + + Visible = function() + return not props.ok() and #props.msg() > 0 + end, + + ui.typography { + text = props.msg + }, + + }, + + create "Frame" { + + Size = UDim2.new(1, 0, 0, 32), + + BackgroundColor3 = ui.theme.bg[2], + AutomaticSize = Enum.AutomaticSize.Y, + + create "UICorner" { + CornerRadius = UDim.new(0, 8) + }, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + Padding = UDim.new(0, 8), + HorizontalFlex = Enum.UIFlexAlignment.SpaceAround, + VerticalAlignment = Enum.VerticalAlignment.Center, + Wraps = true + }, + + ui.row { + spacing = UDim.new(0, 8), + alignitems = Enum.ItemLineAlignment.Center, + row_template, + + ui.typography { + text = "Page:" + }, + + ui.textfield { + size = UDim2.fromOffset(40, 26), + placeholder = "1", + + text = function() + return page_input() + end, + + oninput = page_input, + + enter = function(text) + local n = tonumber(text) + + if n == nil then + page_input(tostring(page())) + else + page(n) + end + end + }, + + ui.typography { + text = function() + return `/ {math.ceil(props.total_entities() / rows())}` + end + }, + }, + + + ui.row { + spacing = UDim.new(0, 8), + alignitems = Enum.ItemLineAlignment.Center, + row_template, + + ui.typography { + text = "Rows:" + }, + + ui.textfield { + size = UDim2.fromOffset(40, 26), + placeholder = "Rows", + + text = function() + return rows_input() + end, + + oninput = rows_input, + + enter = function(text) + local n = tonumber(text) + + if n == nil then + rows_input(tostring(rows())) + else + rows(n) + end + end + }, + }, + + ui.row { + spacing = UDim.new(0, 4), + row_template, + + ui.button { + size = UDim2.fromOffset(26, 26), + text = "", + + accent = function() + return not props.paused() + end, + + activated = function() + props.paused(not props.paused()) + end, + + {LayoutOrder = 10}, + + create "ImageLabel" { + Size = UDim2.fromOffset(24, 24), + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundTransparency = 1, + + ImageColor3 = ui.theme.fg_on_bg_high[3], + + Image = function() + return if props.paused() then "rbxassetid://10735024209" + else "rbxassetid://10734923214" + end + }, + + }, + + show(props.paused, function() + return ui.button { + size = UDim2.fromOffset(26, 26), + text = "", + + activated = function() + props.refresh(true) + end, + + create "ImageLabel" { + Size = UDim2.fromOffset(24, 24), + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundTransparency = 1, + + ImageColor3 = ui.theme.fg_on_bg_high[3], + + Image = "rbxassetid://10734933222" + }, + + } + end) + } + + }, + + + ui.background { + size = UDim2.fromScale(1, 0), + automaticsize = Enum.AutomaticSize.Y, + + create "UICorner" { + CornerRadius = UDim.new(0, 8) + }, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + }, + + ui.tablesheet { + size = UDim2.fromScale(1, 1), + suggested_column_sizes = { 0.1 }, + + column_sizes = function() + local t = {} + for i in props.columns() do + t[i] = 200 + end + t[1] = 50 + return t + end, + columns = props.columns, + + read_value = function(c, r) + local column = props.columns()[c] + if not column then return "" end + return column[r] or "" + end, + + on_click = function(c, r) + if not props.columns()[1][r-1] then return end + spawn_app.spawn_app(entity, { + host = props.host, + vm = props.vm, + id = props.id, + entity = props.columns()[1][r-1] + }) + end, + on_click2 = function() end, + + below = { + + ui.padding { + x = UDim.new(0, 4), + y = UDim.new(0, 2) + }, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8) + }, + + ui.button { + size = UDim2.fromOffset(70, 26), + text = "Previous", + + activated = function() + page(page() - 1) + end, + + disabled = function() + return page() == 1 or props.ok() == false + end + } :: Instance, + + ui.button { + size = UDim2.fromOffset(70, 26), + text = "Next", + + activated = function() + page(page() + 1) + end, + + disabled = function() + local max_pages = math.max(1, math.ceil(props.total_entities() / rows())) + return page() == max_pages or props.ok() == false + end + } :: Instance, + + ui.typography { + position = UDim2.new(0, 4, 0.5, 0), + anchorpoint = Vector2.new(0, 0.5), + + text = function() + return `total entities: {props.total_entities()}\tfrom: {props.from()}\tuntil: {props.upto()}` + end, + } :: Instance, + } + } + + } + + } + + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/system/init.luau b/modules/Jabby/client/apps/system/init.luau new file mode 100644 index 0000000..79a24de --- /dev/null +++ b/modules/Jabby/client/apps/system/init.luau @@ -0,0 +1,81 @@ +local RunService = game:GetService("RunService") + +local vide = require(script.Parent.Parent.Parent.vide) +local loop = require(script.Parent.Parent.Parent.modules.loop) +local remotes = require(script.Parent.Parent.Parent.modules.remotes) +local types = require(script.Parent.Parent.Parent.modules.types) +local widget = require(script.widget) + +local cleanup = vide.cleanup + +local system = { + class_name = "app" :: "app", + name = "System" +} + +type props = { + host: number, + vm: number, + scheduler: number, + system: number, + + name: string + +} + +function system.mount(props: props, destroy: () -> ()) + + local watch_id = math.random(2^31 - 1) + local recording = vide.source(false) + local watching_frame = vide.source(0) + local per_frame_data = vide.source({} :: {[number]: number}) + local changes = vide.source({ + component = {}, + entities = {}, + types = {}, + values = {} + } :: types.WatchLoggedChanges) + + local system_props_data = { + watch_id = watch_id, + host = props.host, + vm = props.vm, + scheduler = props.scheduler, + system = props.system, + name = props.name, + + changes = changes, + recording = recording, + per_frame_data = per_frame_data, + watching_frame = watching_frame, + destroy = destroy, + } + + local app_loop = loop ( + "app-client-system", + system_props_data, + + {i = 1}, + script.systems.replicate + ) + + local outgoing: types.OutgoingConnector = { + host = system_props_data.host, + to_vm = system_props_data.vm + } + + remotes.create_watch:fire(outgoing, props.scheduler, props.system, watch_id) + remotes.connect_watch:fire(outgoing, watch_id) + + cleanup(RunService.Heartbeat:Connect(app_loop)) + cleanup(function() + remotes.disconnect_watch:fire(outgoing, watch_id) + remotes.stop_watch:fire(outgoing, watch_id) + remotes.remove_watch:fire(outgoing, watch_id) + end) + + return widget(system_props_data) + +end + +return system \ No newline at end of file diff --git a/modules/Jabby/client/apps/system/systems/replicate.luau b/modules/Jabby/client/apps/system/systems/replicate.luau new file mode 100644 index 0000000..24a39ce --- /dev/null +++ b/modules/Jabby/client/apps/system/systems/replicate.luau @@ -0,0 +1,97 @@ +local vide = require(script.Parent.Parent.Parent.Parent.Parent.vide) +local queue = require(script.Parent.Parent.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.Parent.Parent.modules.remotes) +local types = require(script.Parent.Parent.Parent.Parent.Parent.modules.types) + +local batch = vide.batch + +type Context = { + host: "server" | Player, + vm: number, + + watch_id: number, + scheduler: number, + system: number, + name: string, + + recording: vide.Source, + watching_frame: vide.Source, + per_frame_data: vide.Source<{[number]: number}>, + changes: (types.WatchLoggedChanges) -> (), +} + +return function(context: Context) + + local watch_id = context.watch_id + local outgoing: types.OutgoingConnector = { + host = context.host, + to_vm = context.vm + } + + local recording_state_changed = false + local recording = false + + vide.effect(function() + recording_state_changed = true + recording = context.recording() + end) + + local watching_frame_changed = false + local watching_frame = 1 + vide.effect(function() + watching_frame_changed = true + watching_frame = context.watching_frame() + end) + + local receive_update_data = queue(remotes.update_watch_data) + local receive_overview = queue(remotes.update_overview) + + return function() + + if recording_state_changed and recording then + remotes.start_record_watch:fire(outgoing, watch_id) + recording_state_changed = false + elseif recording_state_changed and not recording then + remotes.stop_watch:fire(outgoing, watch_id) + recording_state_changed = false + end + + if watching_frame_changed then + remotes.request_watch_data:fire(outgoing, watch_id, watching_frame) + watching_frame_changed = false + end + + debug.profilebegin("receive update data") + batch(function() + for from, watch, frame, changes in receive_update_data:iter() do + if watch ~= watch_id then continue end + if frame ~= watching_frame then continue end + if changes == nil then + context.changes({ + types = {}, + entities = {}, + component = {}, + values = {}, + worlds = {} + }) + else + context.changes(changes) + end + end + end) + debug.profileend() + + debug.profilebegin("receive overview") + batch(function() + for from, watch, frame, value in receive_overview:iter() do + if watch ~= watch_id then continue end + local data = context.per_frame_data() + if data[frame] == value then continue end + data[frame] = value + context.per_frame_data(data) + end + end) + debug.profileend() + + end +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/system/watch_tracker.luau b/modules/Jabby/client/apps/system/watch_tracker.luau new file mode 100644 index 0000000..0f18e05 --- /dev/null +++ b/modules/Jabby/client/apps/system/watch_tracker.luau @@ -0,0 +1,235 @@ +local ui = require(script.Parent.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local types = require(script.Parent.Parent.Parent.Parent.modules.types) +local tooltip = require(script.Parent.Parent.Parent.components.tooltip) +local virtualscroller_horizontal = require(script.Parent.Parent.Parent.components.virtualscroller_horizontal) + +local create = vide.create +local source = vide.source + +type props = { + host: "server" | Player, + vm: number, + + scheduler: number, + system: number, + name: string, + + recording: vide.Source, + watching_frame: vide.Source, + per_frame_data: () -> {[number]: number}, + changes: () -> types.WatchLoggedChanges, +} + +local PROFILER_THICKNESS = 6 + +return function(props: props) + + local is_recording = props.recording + local watching_frame = props.watching_frame + local per_frame_data = props.per_frame_data + local changes = props.changes + + local function sheet_changes() + local changes = changes() + + return { + {"type", unpack(changes.types)}, + {"entity", unpack(changes.entities)}, + {"component", unpack(changes.component)}, + {"value", unpack(changes.values)} + } + end + + local function max() + return math.max(1, unpack(per_frame_data())) + end + + local function total_changes() + local sum = 0 + for _, value in per_frame_data() do + sum += value + end + return sum + end + + local hovering_over = source(false) + + return ui.list { + justifycontent = Enum.UIFlexAlignment.SpaceEvenly, + spacing = UDim.new(0, 4), + + ui.pane { + virtualscroller_horizontal { + item_size = PROFILER_THICKNESS, + item = function(index) + local function value() + return per_frame_data()[index()] or 0 + end + + return create "TextButton" { + Size = function() + return UDim2.new(0, PROFILER_THICKNESS, 1, 0) + end, + BackgroundTransparency = function() + return if hovering_over() == index() then 0.5 else 1 + end, + BackgroundColor3 = ui.theme.bg[10], + + MouseEnter = function() + hovering_over(index()) + end, + MouseLeave = function() + if hovering_over() ~= index() then return end + hovering_over(false) + end, + Activated = function() + watching_frame(index()) + end, + AutoLocalize = false, + + create "Frame" { + Size = function() + return UDim2.fromScale(1, value() / max()) + end, + Position = UDim2.fromScale(1, 1), + AnchorPoint = Vector2.new(1, 1), + BackgroundColor3 = function() + return if watching_frame() == index() then + ui.theme.acc[20]() + elseif hovering_over() == index() then + ui.theme.acc[5]() + else ui.theme.acc[0]() + end, + } + + } + end, + max_items = function() + return #per_frame_data() + end, + + create "UIStroke" {Color = ui.theme.bg[-3]}, + + { + Size = UDim2.new(1, 0, 0, 56), + HorizontalScrollBarInset = Enum.ScrollBarInset.ScrollBar, + BackgroundColor3 = ui.theme.bg[-1], + ScrollBarThickness = 6, + + CanvasPosition = function() + per_frame_data() + return Vector2.new(table.maxn(per_frame_data()) * PROFILER_THICKNESS) + end, + } + }, + + ui.typography { + text = function() + return `Recorded {#per_frame_data()} frames and tracked {total_changes()} changes` + end + }, + + ui.typography { + text = function() + return `Currently viewing frame {watching_frame()}` + end + } + }, + + tooltip { + transparency = 0, + visible = function() + return hovering_over() ~= false + end, + + ui.typography { + automaticsize = Enum.AutomaticSize.XY, + + text = function() + return `\z + Frame: #{hovering_over()}\n\z + Changes: {per_frame_data()[hovering_over()] or 0}` + end, + xalignment = Enum.TextXAlignment.Left, + wrapped = true + } + }, + + ui.container { + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundColor3 = ui.theme.bg[1], + BackgroundTransparency = 0, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalFlex = Enum.UIFlexAlignment.SpaceEvenly, + VerticalFlex = Enum.UIFlexAlignment.Fill, + Padding = UDim.new(0, 8) + }, + + ui.padding { + x = UDim.new(0, 4), + y = UDim.new(0, 4) + }, + + ui.button { + size = UDim2.new(0, 80, 0, 30), + automaticsize = Enum.AutomaticSize.X, + text = function() + return if is_recording() then "Pause" else "Record" + end, + + activated = function() + is_recording(not is_recording()) + end + }, + + ui.container { + Size = UDim2.fromScale(0, 0), + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + }, + + ui.textfield { + size = UDim2.new(1, 0, 1, 0), + text = tostring(watching_frame()), + placeholder = "frame", + + enter = function(text: string) + if tonumber(text) == nil then return end + watching_frame(tonumber(text)) + end + } + }, + + }, + + ui.container { + Size = UDim2.fromScale(1, 1), + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + }, + + ui.tablesheet { + size = UDim2.fromScale(1, 1), + + column_sizes = source {100, 80, 100, 200}, + read_value = function(column, row) + local v = sheet_changes()[column][row] + return if v == false then "" else v + end, + + on_click = function() end, + on_click2 = function() end, + + columns = sheet_changes + } + + } + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/apps/system/widget.luau b/modules/Jabby/client/apps/system/widget.luau new file mode 100644 index 0000000..1d2298b --- /dev/null +++ b/modules/Jabby/client/apps/system/widget.luau @@ -0,0 +1,34 @@ +local ui = require(script.Parent.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local types = require(script.Parent.Parent.Parent.Parent.modules.types) +local watch_tracker = require(script.Parent.watch_tracker) + +type props = { + host: "server" | Player, + vm: number, + + scheduler: number, + system: number, + name: string, + + destroy: () -> (), + + recording: vide.Source, + watching_frame: vide.Source, + per_frame_data: () -> {[number]: number}, + changes: () -> types.WatchLoggedChanges, +} + +return function(props: props) + + return ui.widget { + title = `system - {props.name}`, + subtitle = `host: {props.host} vm: {props.vm} scheduler: {props.scheduler} system: {props.system}`, + bind_to_close = props.destroy, + size = Vector2.new(350, 400), + min_size = Vector2.new(300, 300), + + watch_tracker(props) + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/components/tooltip.luau b/modules/Jabby/client/components/tooltip.luau new file mode 100644 index 0000000..c8f8b43 --- /dev/null +++ b/modules/Jabby/client/components/tooltip.luau @@ -0,0 +1,60 @@ +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") + +local ui = require(script.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.vide) + +local create = vide.create +local source = vide.source +local cleanup = vide.cleanup + +type props = { + visible: boolean | () -> boolean, + transparency: number? | () -> number, + [number]: any +} + +return function(props: props) + + local mouse_location = source(Vector2.zero) + + cleanup(RunService.PreRender:Connect(function() + mouse_location(UserInputService:GetMouseLocation()) + end)) + + return create "ScreenGui" { + Name = "Mouse Hover", + IgnoreGuiInset = true, + DisplayOrder = 1e9, + Enabled = props.visible, + + create "Frame" { + Position = function() + return UDim2.fromOffset( + mouse_location().X + 24, + mouse_location().Y + 24 + ) + end, + Size = UDim2.fromOffset(400, 0), + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundColor3 = ui.theme.bg[0], + BackgroundTransparency = props.transparency or 0.5, + + ui.padding {}, + + create "UICorner" { + CornerRadius = UDim.new(0, 8) + }, + + create "UIStroke" { + Color = ui.theme.bg[-10], + Thickness = 2, + Transparency = 0.8 + }, + + unpack(props) + } + + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/components/virtualscroller_horizontal.luau b/modules/Jabby/client/components/virtualscroller_horizontal.luau new file mode 100644 index 0000000..37612d1 --- /dev/null +++ b/modules/Jabby/client/components/virtualscroller_horizontal.luau @@ -0,0 +1,185 @@ +local ui = require(script.Parent.Parent.Parent.ui) +local vide = require(script.Parent.Parent.Parent.vide) + +local create = vide.create +local source = vide.source +local values = vide.values +local changed = vide.changed +local effect = vide.effect +local untrack = vide.untrack + +type can = T | () -> T +type props = { + + size: can?, + position: can?, + anchorpoint: can?, + + --- streams in items. when index is -1, should expect to be unused + item: (index: () -> number) -> Instance, + --- streams in separators. when index is -1, should expect to be unused + separator: ((index: () -> number) -> Instance)?, + + item_size: number, + separator_size: number?, + + max_items: (() -> number)?, + + [number]: any + +} + +return function(props: props) + + local items = source({} :: {vide.Source}) + + local absolute_size = source(Vector2.zero) + local canvas_position = source(Vector2.zero) + + local item_size = props.item_size + local separator_size = props.separator_size or 0 + + local item = props.item + local separator = props.separator + + local OVERFLOW_ONE_SIDE = 4 + + effect(function() + local absolute_size = absolute_size() + local canvas_position = canvas_position() + + local child_size = item_size + separator_size + local total_required = math.ceil(absolute_size.X / child_size) + OVERFLOW_ONE_SIDE * 2 + local sources = untrack(items) + + local min_index = math.floor(canvas_position.X / child_size) + local max_index = math.ceil((canvas_position.X + absolute_size.X) / child_size) + + local max_items = math.huge + if props.max_items then + max_items = props.max_items() + end + + untrack(function() + -- mark any sources out of range as unused + local unused = {} + + for i, s in sources do + local index = s() + + if + index >= math.max(min_index, 1) + and index <= math.min(max_index, max_items) + then continue end + unused[i] = true + s(-1) + end + + -- add sources necessary + if #sources < total_required then + for i = #sources + 1, total_required do + sources[i] = source(-1) + unused[i] = true + end + items(sources) + end + + -- update indexes of any sources that went unused + local did_not_render = {} + + for i = math.max(min_index, 1), math.min(max_index, max_items) do + did_not_render[i] = true + end + + + for _, s in sources do + did_not_render[s()] = nil + end + + for index in unused do + local s = sources[index] + local key = next(did_not_render) + if not key then break end + s(key) + did_not_render[key] = nil + unused[index] = nil + end + + if next(did_not_render) then warn("missing source!", next(did_not_render)) end + + -- remove unnecessary sources + if #sources > total_required then + for i = #sources, 1, -1 do + if unused[i] then + table.remove(sources, i) + end + unused[i] = nil + if #sources < total_required then break end + end + items(sources) + end + + end) + + end) + + return create "ScrollingFrame" { + + Size = props.size or UDim2.fromScale(1, 1), + Position = props.position, + AnchorPoint = props.anchorpoint, + + BackgroundTransparency = 1, + + CanvasSize = function() + if props.max_items then + return UDim2.fromOffset(props.max_items() * (item_size + separator_size), 0) + else + local absolute_size = absolute_size() + local canvas_position = canvas_position() + local child_size = item_size + separator_size + local max_index = math.ceil((canvas_position.X + absolute_size.X) / child_size) + OVERFLOW_ONE_SIDE + return UDim2.fromOffset(max_index * child_size, 0) + end + end, + + values(items, function(index) + return create "Frame" { + Name = index, + + Position = function() + if index() == -1 then UDim2.fromOffset(0, -1000) end + return UDim2.fromOffset( + (item_size + separator_size) * (index() - 1), + 0 + ) + end, + Size = UDim2.new(0, item_size + separator_size, 1, 0), + + BackgroundTransparency = 1, + + ui.container { + Name = "Item", + + item(index), + }, + + if separator then + ui.container { + Name = "Separator", + + separator(index) + } + else nil, + + } + end), + + changed("AbsoluteSize", absolute_size), + changed("CanvasPosition", canvas_position), + + unpack(props), + + } + +end \ No newline at end of file diff --git a/modules/Jabby/client/init.luau b/modules/Jabby/client/init.luau new file mode 100644 index 0000000..22a9f16 --- /dev/null +++ b/modules/Jabby/client/init.luau @@ -0,0 +1,19 @@ +local entity = require(script.apps.entity) +local home = require(script.apps.home) +local overview_scheduler = require(script.apps.overview_scheduler) +local registry = require(script.apps.registry) +local spawn_app = require(script.spawn_app) + +return { + + apps = { + home = home, + entity = entity, + scheduler = overview_scheduler, + registry = registry, + }, + + spawn_app = spawn_app.spawn_app, + unmount_all = spawn_app.unmount_all + +} \ No newline at end of file diff --git a/modules/Jabby/client/spawn_app.luau b/modules/Jabby/client/spawn_app.luau new file mode 100644 index 0000000..fde4356 --- /dev/null +++ b/modules/Jabby/client/spawn_app.luau @@ -0,0 +1,36 @@ +local Players = game:GetService("Players") + +local vide = require(script.Parent.Parent.vide) +local types = require(script.Parent.Parent.modules.types) + +local destroy_fn = {} + +local function unmount_all() + for destroy in destroy_fn do + destroy() + end +end + +local function spawn_app(app: types.Application, props: T): () -> () + return vide.root(function(destroy) + + local destroy = function() + destroy_fn[destroy] = nil + destroy() + end + + local application = app.mount(props, destroy) + application.Parent = Players.LocalPlayer.PlayerGui + + vide.cleanup(application) + + destroy_fn[destroy] = true + + return destroy + end) +end + +return { + unmount_all = unmount_all, + spawn_app = spawn_app +} \ No newline at end of file diff --git a/modules/Jabby/examples/example.luau b/modules/Jabby/examples/example.luau new file mode 100644 index 0000000..bf923d2 --- /dev/null +++ b/modules/Jabby/examples/example.luau @@ -0,0 +1,46 @@ +local RunService = game:GetService("RunService") + +local jecs = require("@jecs") +local jabby = require("@modules/jabby") + +local world = jecs.world() + +jabby.set_check_function(function() + return true +end) + +local scheduler = jabby.scheduler.create() + +jabby.register({ + applet = jabby.applets.scheduler, + name = "Example Scheduler", + configuration = { + scheduler = scheduler, + }, +}) + +jabby.register({ + applet = jabby.applets.world, + name = "Example World", + configuration = { + world = world, + }, +}) + +local system_id = scheduler:register_system({ + name = "example_system", + module = script, +}) + +local function example_system(_world: jecs.World, dt: number) + return dt +end + +if RunService:IsClient() then + local client = jabby.obtain_client() + client.spawn_app(client.apps.home, nil) +end + +RunService.Heartbeat:Connect(function(dt) + scheduler:run(system_id, example_system, world, dt) +end) diff --git a/modules/Jabby/jecs.luau b/modules/Jabby/jecs.luau new file mode 100644 index 0000000..afcac63 --- /dev/null +++ b/modules/Jabby/jecs.luau @@ -0,0 +1,4115 @@ +--!optimize 2 +--!native +--!strict +--draft 4 + +type i53 = number +type i24 = number + +type Ty = { Entity } +type ArchetypeId = number + +type Column = { any } + +type Map = { [K]: V } + +export type Archetype = { + id: number, + types: Ty, + type: string, + entities: { Entity }, + columns: { Column }, + columns_map: { [Component]: Column } +} + +export type QueryInner = { + compatible_archetypes: { Archetype }, + archetypes_map: { [number]: number }, + ids: { Component }, + filter_with: { Component }, + filter_without: { Component }, + next: () -> (Entity, ...any), + -- world: World, +} + +type function ecs_entity_t(ty: type) + if ty:is("union") then + for _, component in ty:components() do + assert(not component:readproperty(types.singleton("__IS_PAIR")), "Expected Entity got Pair") + end + end + assert(ty:readproperty(types.singleton("__T")), "Expected Entity") + assert(not ty:readproperty(types.singleton("__IS_PAIR")), "Expected Entity got Pair") + return ty +end + +type function ecs_pair_t(first: type, second: type) + local __T = types.singleton("__T") + local __IS_PAIR = types.singleton("__IS_PAIR") + + local first_t = first:readproperty(__T) + local second_t = second:readproperty(__T) + + assert(first_t and second_t, "Expected at least one Entity in pair") + + local id = types.newtable() + local ty = if first_t:is("nil") then second_t else first_t + + id:setproperty(__T, ty) + id:setproperty(__IS_PAIR, types.singleton(true)) + + return id +end + +type function ecs_id_t(first: type, second: type) + local __T = types.singleton("__T") + + local p = ecs_pair_t(Entity(first), Entity(second)) + if second:is("nil") then + return first + end + + return p +end + +export type Entity = { __T: T } +export type Id = { __T: T } +export type Pair = ecs_pair_t, Entity> +export type Component = { __T: T } +export type Id2 = ecs_id_t + +export type Item = (self: Query) -> (Entity, T...) +export type Iter = (query: Query) -> () -> (Entity, T...) +export type Cached_Query_Iter = (query: Cached_Query) -> () -> (Entity, T...) + +type TypePack = (T...) -> never + +export type Cached_Query = typeof(setmetatable( + {} :: { + iter: Cached_Query_Iter, + archetypes: (Cached_Query, override: boolean?) -> { Archetype }, + has: (Cached_Query, Entity) -> boolean, + fini: (Cached_Query) -> (), + + ids: { Id }, + filter_with: { Id }?, + filter_without: { Id }?, + archetypes_map: { [number]: number }, + -- world: World + }, + {} :: { + __iter: Cached_Query_Iter, + }) +) + +export type Query = typeof(setmetatable( + {} :: { + iter: Iter, + with: ((Query, ...Component) -> Query), + without: ((Query, ...Component) -> Query), + archetypes: (Query) -> { Archetype }, + cached: (Query) -> Cached_Query, + has: (Query, Entity) -> boolean, + ids: { Id }, + filter_with: { Id }?, + filter_without: { Id }?, + -- world: World + }, + {} :: { + __iter: Iter, + } +)) + +type QueryArm = () -> () + +export type Observer = { + callback: (archetype: Archetype) -> (), + query: QueryInner, +} + +type query = { + compatible_archetypes: { archetype }, + ids: { i53 }, + filter_with: { i53 }, + filter_without: { i53 }, + next: () -> (i53, ...any), + world: world, +} + +export type observer = { + callback: (archetype: archetype) -> (), + query: query, +} + +type archetype = { + id: number, + types: { i53 }, + type: string, + entities: { i53 }, + columns: { Column }, + columns_map: { [i53]: Column } +} + +type componentrecord = { + component: i53, + records: { [number]: number }, + counts: { [i53]: number }, + flags: number, + size: number, + + on_add: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?, + on_change: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?, + on_remove: ((entity: i53, id: i53, delete: boolean?) -> ())?, + + wildcard_pairs: { [number]: componentrecord }, +} +type record = { + archetype: archetype, + row: number, + dense: i24, +} +type entityindex = { + dense_array: Map, + sparse_array: Map, + alive_count: number, + max_id: number, + range_begin: number?, + range_end: number?, +} +type world = { + archetype_edges: Map>, + archetype_index: { [string]: archetype }, + archetypes: { [i53]: archetype }, + component_index: Map, + entity_index: entityindex, + ROOT_ARCHETYPE: archetype, + + max_component_id: number, + max_archetype_id: number, + + observable: Map>, + + range: (self: world, range_begin: number, range_end: number?) -> (), + entity: (self: world, id: i53?) -> i53, + component: (self: world) -> i53, + target: (self: world, id: i53, relation: i53, index: number?) -> i53?, + delete: (self: world, id: i53) -> (), + add: (self: world, id: i53, component: i53) -> (), + set: (self: world, id: i53, component: i53, data: any) -> (), + cleanup: (self: world) -> (), + clear: (self: world, entity: i53) -> (), + remove: (self: world, id: i53, component: i53) -> (), + get: (world, ...i53) -> (), + has: (world, ...i53) -> boolean, + parent: (self: world, entity: i53) -> i53?, + contains: (self: world, entity: i53) -> boolean, + exists: (self: world, entity: i53) -> boolean, + each: (self: world, id: i53) -> () -> i53, + children: (self: world, id: i53) -> () -> i53, + query: (world, ...i53) -> Query<...any>, + + added: (world, i53, (e: i53, id: i53, value: any?) -> ()) -> () -> (), + changed: (world, i53, (e: i53, id: i53, value: any?) -> ()) -> () -> (), + removed: (world, i53, (e: i53, id: i53, delete: boolean?) -> ()) -> () -> (), +} + +export type World = { + archetype_edges: Map>, + archetype_index: { [string]: Archetype }, + archetypes: Archetypes, + component_index: ComponentIndex, + entity_index: EntityIndex, + ROOT_ARCHETYPE: Archetype, + + max_component_id: number, + max_archetype_id: number, + + observable: Map>, + + added: (World, Component, (e: Entity, id: Id, value: T, oldarchetype: Archetype) -> ()) -> () -> (), + removed: (World, Component, (e: Entity, id: Id, delete: boolean?) -> ()) -> () -> (), + changed: (World, Component, (e: Entity, id: Id, value: T, oldarchetype: Archetype) -> ()) -> () -> (), + + --- Enforce a check on entities to be created within desired range + range: (self: World, range_begin: number, range_end: number?) -> (), + + --- Creates a new entity + entity: + & ((self: World) -> Entity) + & ((self: World, id: Entity) -> Entity) + & ((self: World, id: number) -> Entity) + & ((self: World, id: Component) -> Component), + --- Creates a new entity located in the first 256 ids. + --- These should be used for static components for fast access. + component: (self: World) -> Entity, + + --- Gets the target of an relationship. For example, when a user calls + --- `world:target(id, ChildOf(parent), 0)`, you will obtain the parent entity. + target: ( + self: World, + id: Entity | number, + relation: ecs_entity_t, + index: number? + ) -> Entity?, + + --- Deletes an entity and all it's related components and relationships. + delete: (self: World, id: Entity) -> (), + + --- Adds a component to the entity with no value + add: ( + self: World, + id: ecs_entity_t>, + component: Component + ) -> (), + + --- Assigns a value to a component on the given entity + set: (self: World, id: Entity, component: Component, data: a) -> (), + + cleanup: (self: World) -> (), + + -- Removes all components from the entity + clear: (self: World, entity: Entity) -> (), + --- Removes a component from the given entity + remove: (self: World, id: Entity, component: Component) -> (), + --- Retrieves the value of up to 4 components. These values may be nil. + get: & ((World, Entity | number, Component) -> a?) + & ((World, Entity | number, Component, Component) -> (a?, b?)) + & ((World, Entity | number, Component, Component, Component) -> (a?, b?, c?)) + & ((World, Entity | number, Component, Component, Component, Component) -> (a?, b?, c?, d?)), + + --- Returns whether the entity has the ID. + has: ((World, Entity, Component) -> boolean) + & ((World, Entity, Component, Component) -> boolean) + & ((World, Entity, Component, Component, Component) -> boolean) + & (World, Entity, Component, Component, Component, Component) -> boolean, + + --- Get parent (target of ChildOf relationship) for entity. If there is no ChildOf relationship pair, it will return nil. + parent: (self: World, entity: Entity) -> Entity?, + + --- Checks if the world contains the given entity + contains: (self: World, entity: Entity) -> boolean, + + --- Checks if the entity exists + exists: (self: World, entity: Entity) -> boolean, + + each: (self: World, id: Id) -> () -> Entity, + + children: (self: World, id: Id) -> () -> Entity, + + --- Searches the world for entities that match a given query + query: ((World) -> Query) + & ((World, Component) -> Query) + & ((World, Component, Component) -> Query) + & ((World, Component, Component, Component) -> Query) + & ((World, Component, Component, Component, Component) -> Query) + & ((World, Component, Component, Component, Component, Component) -> Query) + & ((World, Component, Component, Component, Component, Component, Component) -> Query) + & (( + World, + Component, + Component, + Component, + Component, + Component, + Component, + Component + ) -> Query) + & (( + World, + Component, + Component, + Component, + Component, + Component, + Component, + Component, + Component, + ...Component + ) -> Query), +} + +export type Record = { + archetype: Archetype, + row: number, + dense: i24, +} +export type ComponentRecord = { + records: { [i24]: number }, + counts: { [i24]: number }, + flags: number, + size: number, + + on_add: ((entity: Entity, id: Entity, value: T, oldarchetype: Archetype) -> ())?, + on_change: ((entity: Entity, id: Entity, value: T, oldArchetype: Archetype) -> ())?, + on_remove: ((entity: Entity, id: Entity) -> ())?, +} +export type ComponentIndex = Map +export type Archetypes = { [i24]: Archetype } + +export type EntityIndex = { + dense_array: Map, + sparse_array: Map, + alive_count: number, + max_id: number, + range_begin: number?, + range_end: number?, +} + +-- stylua: ignore start + +local ECS_ENTITY_MASK = bit32.lshift(1, 24) +local ECS_GENERATION_MASK = bit32.lshift(1, 16) +local ECS_PAIR_OFFSET = 2^48 + +local ECS_ID_DELETE = 0b0001 +local ECS_ID_IS_TAG = 0b0010 +local ECS_ID_IS_EXCLUSIVE = 0b0100 +local ECS_ID_MASK = 0b0000 + +local HI_COMPONENT_ID = 256 +local EcsOnAdd = HI_COMPONENT_ID + 1 +local EcsOnRemove = HI_COMPONENT_ID + 2 +local EcsOnChange = HI_COMPONENT_ID + 3 +local EcsWildcard = HI_COMPONENT_ID + 4 +local EcsChildOf = HI_COMPONENT_ID + 5 +local EcsComponent = HI_COMPONENT_ID + 6 +local EcsOnDelete = HI_COMPONENT_ID + 7 +local EcsOnDeleteTarget = HI_COMPONENT_ID + 8 +local EcsDelete = HI_COMPONENT_ID + 9 +local EcsRemove = HI_COMPONENT_ID + 10 +local EcsName = HI_COMPONENT_ID + 11 +local EcsOnArchetypeCreate = HI_COMPONENT_ID + 12 +local EcsOnArchetypeDelete = HI_COMPONENT_ID + 13 +local EcsExclusive = HI_COMPONENT_ID + 14 +local EcsRest = HI_COMPONENT_ID + 15 + +local NULL_ARRAY = table.freeze({}) :: Column +local NULL = newproxy(false) + +local ECS_INTERNAL_ERROR_INVALID_ENTITIES = [[ + You tried passing a pair that has invalid entities that are either unalive + or non-existing entities. You can enable DEBUG mode by passing in true to + jecs.world(true) and try doing it again in order to get better assertions so + that you can understand what went wrong. +]] + +local function ecs_assert(condition, msg: string?) + if not condition then + error(msg) + end +end + +local ecs_metadata: Map> = {} +local ecs_max_component_id = 0 +local ecs_max_tag_id = EcsRest + +local function ECS_COMPONENT() + ecs_max_component_id += 1 + if ecs_max_component_id > HI_COMPONENT_ID then + error("Too many components") + end + return ecs_max_component_id +end + +local function ECS_TAG() + ecs_max_tag_id += 1 + return ecs_max_tag_id +end + +local function ECS_META(id: i53, ty: i53, value: any?) + local bundle = ecs_metadata[id] + if bundle == nil then + bundle = {} :: Map + ecs_metadata[id] = bundle + end + bundle[ty] = if value == nil then NULL else value +end + +local function ECS_META_RESET() + ecs_metadata = {} + ecs_max_component_id = 0 + ecs_max_tag_id = EcsRest +end + +local function ECS_COMBINE(id: number, generation: number): i53 + return id + (generation * ECS_ENTITY_MASK) +end + +local function ECS_IS_PAIR(e: number): boolean + return e > ECS_PAIR_OFFSET +end + +local function ECS_GENERATION_INC(e: i53): i53 + if e > ECS_ENTITY_MASK then + local id = e % ECS_ENTITY_MASK + local generation = e // ECS_ENTITY_MASK + + local next_gen = generation + 1 + if next_gen >= ECS_GENERATION_MASK then + return id + end + + return ECS_COMBINE(id, next_gen) + end + return ECS_COMBINE(e, 1) +end + +local function ECS_ENTITY_T_LO(e: i53): i24 + return e % ECS_ENTITY_MASK +end + +local function ECS_ID(e: i53) + return e % ECS_ENTITY_MASK +end + +local function ECS_GENERATION(e: i53) + return e // ECS_ENTITY_MASK +end + +local function ECS_ENTITY_T_HI(e: i53): i24 + return e // ECS_ENTITY_MASK +end + +local function ECS_PAIR(pred: i53, obj: i53): i53 + pred %= ECS_ENTITY_MASK + obj %= ECS_ENTITY_MASK + + return obj + (pred * ECS_ENTITY_MASK) + ECS_PAIR_OFFSET +end + +local function ECS_PAIR_FIRST(e: i53): i24 + return (e - ECS_PAIR_OFFSET) // ECS_ENTITY_MASK +end + +local function ECS_PAIR_SECOND(e: i53): i24 + return (e - ECS_PAIR_OFFSET) % ECS_ENTITY_MASK +end + +local function entity_index_try_get_any( + entity_index: entityindex, + entity: i53 +): record? + local r = entity_index.sparse_array[ECS_ID(entity)] + + if not r or r.dense == 0 then + return nil + end + + return r +end + +local function entity_index_try_get(entity_index: entityindex, entity: i53): record? + local r = entity_index_try_get_any(entity_index, entity) + if r then + local r_dense = r.dense + if r_dense > entity_index.alive_count then + return nil + end + if entity_index.dense_array[r_dense] ~= entity then + return nil + end + end + return r +end + +local function entity_index_try_get_fast(entity_index: entityindex, entity: i53): record? + local r = entity_index_try_get_any(entity_index, entity) + if r then + local r_dense = r.dense + -- if r_dense > entity_index.alive_count then + -- return nil + -- end + if entity_index.dense_array[r_dense] ~= entity then + return nil + end + end + return r +end + +local function entity_index_is_alive(entity_index: entityindex, entity: i53): boolean + return entity_index_try_get(entity_index, entity) ~= nil +end + +local function entity_index_get_alive(entity_index: entityindex, entity: i53): i53? + local r = entity_index_try_get_any(entity_index, entity :: number) + if r then + return entity_index.dense_array[r.dense] + end + return nil +end + +local function ecs_get_alive(world: world, entity: i53): i53 + if entity == 0 then + return 0 + end + + local eindex = world.entity_index + + if entity_index_is_alive(eindex, entity) then + return entity + end + + if (entity :: number) > ECS_ENTITY_MASK then + return 0 + end + + local current = entity_index_get_alive(eindex, entity) + if not current or not entity_index_is_alive(eindex, current) then + return 0 + end + + return current +end + +local ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY = "Entity is outside range" + +local function ENTITY_INDEX_NEW_ID(entity_index: entityindex): i53 + local dense_array = entity_index.dense_array + local alive_count = entity_index.alive_count + local sparse_array = entity_index.sparse_array + local max_id = entity_index.max_id + + if alive_count < max_id then + alive_count += 1 + entity_index.alive_count = alive_count + local id = dense_array[alive_count] + return id + end + + local id = max_id + 1 + local range_end = entity_index.range_end + ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY) + + entity_index.max_id = id + alive_count += 1 + entity_index.alive_count = alive_count + dense_array[alive_count] = id + sparse_array[id] = { dense = alive_count } :: record + + return id +end + +local function ecs_pair_first(world: world, e: i53) + local pred = ECS_PAIR_FIRST(e) + return ecs_get_alive(world, pred) +end + +local function ecs_pair_second(world: world, e: i53) + local obj = ECS_PAIR_SECOND(e) + return ecs_get_alive(world, obj) +end + +local function query_match(query: query, archetype: archetype) + local columns_map = archetype.columns_map + local with = query.filter_with + + for _, id in with do + if not columns_map[id] then + return false + end + end + + local without = query.filter_without + if without then + for _, id in without do + if columns_map[id] then + return false + end + end + end + + return true +end + +local function find_observers(world: world, event: i53, component: i53): { observer }? + local cache = world.observable[event] + if not cache then + return nil + end + return cache[component] :: any +end + +local function archetype_move( + entity_index: entityindex, + entity: i53, + to: archetype, + dst_row: i24, + from: archetype, + src_row: i24 +) + local src_columns = from.columns + local dst_entities = to.entities + local src_entities = from.entities + + local last = #src_entities + local id_types = from.types + local columns_map = to.columns_map + + if src_row ~= last then + -- If the entity is the last row in the archetype then swapping it would be meaningless. + + for i, column in src_columns do + if column == NULL_ARRAY then + continue + end + -- Retrieves the new column index from the source archetype's record from each component + -- We have to do this because the columns are tightly packed and indexes may not correspond to each other. + local dst_column = columns_map[id_types[i]] + + -- Sometimes target column may not exist, e.g. when you remove a component. + if dst_column then + dst_column[dst_row] = column[src_row] + end + + -- Swap rempves columns to ensure there are no holes in the archetype. + column[src_row] = column[last] + column[last] = nil + end + + + -- Move the entity from the source to the destination archetype. + -- Because we have swapped columns we now have to update the records + -- corresponding to the entities' rows that were swapped. + + local e2 = src_entities[last] + src_entities[src_row] = e2 + + local sparse_array = entity_index.sparse_array + local record2 = sparse_array[ECS_ID(e2)] + record2.row = src_row + else + for i, column in src_columns do + if column == NULL_ARRAY then + continue + end + -- Retrieves the new column index from the source archetype's record from each component + -- We have to do this because the columns are tightly packed and indexes may not correspond to each other. + local dst_column = columns_map[id_types[i]] + + -- Sometimes target column may not exist, e.g. when you remove a component. + if dst_column then + dst_column[dst_row] = column[src_row] + end + + column[last] = nil + end + end + + src_entities[last] = nil + dst_entities[dst_row] = entity +end + +local function archetype_append( + entity: i53, + archetype: archetype +): number + local entities = archetype.entities + local length = #entities + 1 + entities[length] = entity + return length +end + +local function new_entity( + entity: i53, + record: record, + archetype: archetype +): record + local row = archetype_append(entity, archetype) + record.archetype = archetype + record.row = row + return record +end + +local function entity_move( + entity_index: entityindex, + entity: i53, + record: record, + to: archetype +) + local sourceRow = record.row + local from = record.archetype + local dst_row = archetype_append(entity, to) + archetype_move(entity_index, entity, to, dst_row, from, sourceRow) + record.archetype = to + record.row = dst_row +end + +local function hash(arr: { i53 }): string + return table.concat(arr, "_") +end + +local function fetch(id: i53, columns_map: { [i53]: Column }, row: number): any + local column = columns_map[id] + + if not column then + return nil + end + + return column[row] +end + +local function WORLD_GET(world: world, entity: i53, + a: i53, b: i53?, c: i53?, d: i53?, e: i53?): ...any + local record = entity_index_try_get(world.entity_index, entity) + if not record then + return nil + end + + local archetype = record.archetype + if not archetype then + return nil + end + + local columns_map = archetype.columns_map + local row = record.row + + local va = fetch(a, columns_map, row) + + if not b then + return va + elseif not c then + return va, fetch(b, columns_map, row) + elseif not d then + return va, fetch(b, columns_map, row), fetch(c, columns_map, row) + elseif not e then + return va, fetch(b, columns_map, row), fetch(c, columns_map, row), fetch(d, columns_map, row) + else + error("args exceeded") + end +end + +local function WORLD_HAS(world: world, entity: i53, id: i53): boolean + local record = entity_index_try_get(world.entity_index, entity) + if not record then + return false + end + + local archetype = record.archetype + if not archetype then + return false + end + + return archetype.columns_map[id] ~= nil +end + +local function WORLD_TARGET(world: world, entity: i53, relation: i53, index: number?): i53? + local entity_index = world.entity_index + local record = entity_index_try_get(entity_index, entity) + if not record then + return nil + end + + local archetype = record.archetype + if not archetype then + return nil + end + + local r = ECS_PAIR(relation, EcsWildcard) + local idr = world.component_index[r] + + if not idr then + return nil + end + + local archetype_id = archetype.id + local count = idr.counts[archetype_id] + if not count then + return nil + end + + local nth = index or 0 + + if nth >= count then + return nil + end + + nth = archetype.types[nth + idr.records[archetype_id]] + + if not nth then + return nil + end + + return entity_index_get_alive(entity_index, + ECS_PAIR_SECOND(nth :: number)) +end + +local function ECS_ID_IS_WILDCARD(e: i53): boolean + local first = ECS_ENTITY_T_HI(e) + local second = ECS_ENTITY_T_LO(e) + return first == EcsWildcard or second == EcsWildcard +end + +local function id_record_get(world: World, id: Entity): ComponentRecord? + local component_index = world.component_index + local idr: ComponentRecord = component_index[id] + + if idr then + return idr + end + + return nil +end + +local function id_record_create( + world: world, + component_index: Map, + id: i53 +): componentrecord + local entity_index = world.entity_index + local flags = ECS_ID_MASK + local relation = id + local target = 0 + local is_pair = ECS_IS_PAIR(id :: number) + + local has_delete = false + local is_exclusive = false + + if is_pair then + relation = entity_index_get_alive(entity_index, ECS_PAIR_FIRST(id)) :: i53 + ecs_assert(relation and entity_index_is_alive( + entity_index, relation), ECS_INTERNAL_ERROR_INVALID_ENTITIES) + target = entity_index_get_alive(entity_index, ECS_PAIR_SECOND(id)) :: i53 + ecs_assert(target and entity_index_is_alive( + entity_index, target), ECS_INTERNAL_ERROR_INVALID_ENTITIES) + + local cleanup_policy_target = WORLD_TARGET(world, relation, EcsOnDeleteTarget, 0) + + if cleanup_policy_target == EcsDelete then + has_delete = true + end + + if WORLD_HAS(world, relation, EcsExclusive) then + is_exclusive = true + end + end + + local cleanup_policy = WORLD_TARGET(world, relation, EcsOnDelete, 0) + + if cleanup_policy == EcsDelete then + has_delete = true + end + + local on_add, on_change, on_remove = WORLD_GET(world, + relation, EcsOnAdd, EcsOnChange, EcsOnRemove) + + local is_tag = not WORLD_HAS(world, + relation, EcsComponent) + + if is_tag and is_pair then + is_tag = not WORLD_HAS(world, target, EcsComponent) + end + + flags = bit32.bor( + flags, + if has_delete then ECS_ID_DELETE else 0, + if is_tag then ECS_ID_IS_TAG else 0, + if is_exclusive then ECS_ID_IS_EXCLUSIVE else 0 + ) + + local idr = { + size = 0, + records = {}, + counts = {}, + flags = flags, + + on_add = on_add, + on_change = on_change, + on_remove = on_remove, + } :: componentrecord + + component_index[id] = idr + + return idr +end + +local function id_record_ensure(world: world, id: i53): componentrecord + local component_index = world.component_index + local idr: componentrecord? = component_index[id] + + if idr then + return idr + end + + return id_record_create(world, component_index, id) +end + +local function archetype_append_to_records( + idr: componentrecord, + archetype_id: number, + columns_map: { [i53]: Column }, + id: i53, + index: number, + column: Column +) + local idr_records = idr.records + local idr_counts = idr.counts + local tr = idr_records[archetype_id] + if not tr then + idr_records[archetype_id] = index + idr_counts[archetype_id] = 1 + columns_map[id] = column + else + local max_count = idr_counts[archetype_id] + 1 + idr_counts[archetype_id] = max_count + end +end + +local function archetype_create(world: world, id_types: { i53 }, ty, prev: i53?): archetype + local archetype_id = (world.max_archetype_id :: number) + 1 + world.max_archetype_id = archetype_id + + local length = #id_types + local columns = (table.create(length) :: any) :: { Column } + + local columns_map: { [i53]: Column } = {} + + local archetype: archetype = { + columns = columns, + columns_map = columns_map, + entities = {}, + id = archetype_id, + type = ty, + types = id_types + } + + for i, component_id in archetype.types do + local idr = id_record_ensure(world, component_id) + idr.size += 1 + local is_tag = bit32.btest(idr.flags, ECS_ID_IS_TAG) + local column = if is_tag then NULL_ARRAY else {} + columns[i] = column + + archetype_append_to_records(idr, archetype_id, columns_map, component_id :: number, i, column) + + if ECS_IS_PAIR(component_id) then + local relation = ECS_PAIR_FIRST(component_id) + local object = ECS_PAIR_SECOND(component_id) + + local r = ECS_PAIR(relation, EcsWildcard) + local idr_r = id_record_ensure(world, r) + + idr_r.size += 1 + archetype_append_to_records(idr_r, archetype_id, columns_map, r, i, column) + local idr_r_wc_pairs = idr_r.wildcard_pairs + if not idr_r_wc_pairs then + idr_r_wc_pairs = {} :: {[i53]: componentrecord } + idr_r.wildcard_pairs = idr_r_wc_pairs + end + idr_r_wc_pairs[component_id] = idr + + local t = ECS_PAIR(EcsWildcard, object) + local idr_t = id_record_ensure(world, t) + + idr_t.size += 1 + archetype_append_to_records(idr_t, archetype_id, columns_map, t, i, column) + end + end + + world.archetype_index[ty] = archetype + world.archetypes[archetype_id] = archetype + world.archetype_edges[archetype_id] = {} :: Map + + for id in columns_map do + local observer_list = find_observers(world, EcsOnArchetypeCreate, id) + if not observer_list then + continue + end + for _, observer in observer_list do + if query_match(observer.query, archetype) then + observer.callback(archetype::any) + end + end + end + + return archetype +end + +local function world_range(world: world, range_begin: number, range_end: number?) + local entity_index = world.entity_index + + entity_index.range_begin = range_begin + entity_index.range_end = range_end + + local max_id = entity_index.max_id + + if range_begin > max_id then + local dense_array = entity_index.dense_array + local sparse_array = entity_index.sparse_array + + for i = max_id + 1, range_begin do + dense_array[i] = i + sparse_array[i] = { + dense = 0 + } :: record + end + entity_index.max_id = range_begin + entity_index.alive_count = range_begin + end +end + +local function archetype_ensure(world: world, id_types: { i53 }): archetype + if #id_types < 1 then + return world.ROOT_ARCHETYPE + end + + local ty = hash(id_types) + local archetype = world.archetype_index[ty] + if archetype then + return archetype + end + + return archetype_create(world, id_types, ty) +end + +local function find_insert(id_types: { i53 }, toAdd: i53): number + for i, id in id_types do + if id == toAdd then + return -1 + end + if id > toAdd then + return i + end + end + return #id_types + 1 +end + +local function find_archetype_without( + world: world, + node: archetype, + id: i53 +): archetype + local id_types = node.types + local at = table.find(id_types, id) + + local dst = table.clone(id_types) + table.remove(dst, at) + + return archetype_ensure(world, dst) +end + + +local function create_edge_for_remove( + world: world, + node: archetype, + edge: Map, + id: i53 +): archetype + local to = find_archetype_without(world, node, id) + local edges = world.archetype_edges + local archetype_id = node.id + edges[archetype_id][id] = to + edges[to.id][id] = node + return to +end + +local function archetype_traverse_remove( + world: world, + id: i53, + from: archetype +): archetype + local edges = world.archetype_edges + local edge = edges[from.id] + + local to: archetype = edge[id] + if to == nil then + to = find_archetype_without(world, from, id) + edge[id] = to + edges[to.id][id] = from + end + + return to +end + +local function find_archetype_with( + world: world, + id: i53, + from: archetype +): archetype + local id_types = from.types + local dst = table.clone(id_types) + + local at = find_insert(id_types :: { number } , id :: number) + + table.insert(dst, at, id) + + return archetype_ensure(world, dst) +end + +local function archetype_traverse_add( + world: world, + id: i53, + from: archetype +): archetype + from = from or world.ROOT_ARCHETYPE + if from.columns_map[id] then + return from + end + local edges = world.archetype_edges + local edge = edges[from.id] + + local to = edge[id] + if not to then + to = find_archetype_with(world, id, from) + edge[id] = to + edges[to.id][id] = from + end + + return to +end + +local function archetype_fast_delete_last(columns: { Column }, column_count: number) + for i, column in columns do + if column ~= NULL_ARRAY then + column[column_count] = nil + end + end +end + +local function archetype_fast_delete(columns: { Column }, column_count: number, row: number) + for i, column in columns do + if column ~= NULL_ARRAY then + column[row] = column[column_count] + column[column_count] = nil + end + end +end + +local function archetype_delete(world: world, archetype: archetype, row: number) + local entity_index = world.entity_index + local columns = archetype.columns + local entities = archetype.entities + local column_count = #entities + local last = #entities + local move = entities[last] + -- We assume first that the entity is the last in the archetype + + if row ~= last then + local record_to_move = entity_index_try_get_any(entity_index, move) + if record_to_move then + record_to_move.row = row + end + + entities[row] = move + end + + entities[last] = nil :: any + + if row == last then + archetype_fast_delete_last(columns, column_count) + else + archetype_fast_delete(columns, column_count, row) + end +end + + +local function archetype_destroy(world: world, archetype: archetype) + if archetype == world.ROOT_ARCHETYPE then + return + end + + local component_index = world.component_index + local archetype_edges = world.archetype_edges + local edges = archetype_edges[archetype.id] + for id, node in edges do + archetype_edges[node.id][id] = nil + edges[id] = nil + end + + local archetype_id = archetype.id + world.archetypes[archetype_id] = nil :: any + world.archetype_index[archetype.type] = nil :: any + local columns_map = archetype.columns_map + + for id in columns_map do + local idr = component_index[id] + idr.records[archetype_id] = nil :: any + idr.counts[archetype_id] = nil + idr.size -= 1 + if idr.size == 0 then + component_index[id] = nil :: any + end + local observer_list = find_observers(world, EcsOnArchetypeDelete, id) + if not observer_list then + continue + end + for _, observer in observer_list do + if query_match(observer.query, archetype) then + observer.callback(archetype::any) + end + end + end +end + +local function NOOP() end + +local function query_archetypes(query: query, override: boolean?) + local compatible_archetypes = query.compatible_archetypes + if not compatible_archetypes or override then + compatible_archetypes = {} + query.compatible_archetypes = compatible_archetypes + + local world = query.world + local archetypes = world.archetypes + + local component_index = world.component_index + + local idr: componentrecord? + local with = query.filter_with + for _, id in with do + local map = component_index[id] + if not map then + continue + end + + if idr == nil or (map.size :: number) < (idr.size :: number) then + idr = map + end + end + + if idr == nil then + return compatible_archetypes + end + + local without = query.filter_without + + for archetype_id in idr.records do + local archetype = archetypes[archetype_id] + local columns_map = archetype.columns_map + local skip = false + for _, component in with do + if not columns_map[component] then + skip = true + break + end + end + if skip then + continue + end + if without then + for _, component in without do + if columns_map[component] then + skip = true + break + end + end + end + + if skip then + continue + end + + table.insert(compatible_archetypes, archetype) + end + end + return compatible_archetypes +end + +local function query_with(query: query, ...: i53) + local ids = query.ids + local with = { ... } + table.move(ids, 1, #ids, #with + 1, with) + query.filter_with = with + return query +end + +local function query_without(query: query, ...: i53) + local without = { ... } + query.filter_without = without + return query +end + +local function query_iter_init(query: QueryInner): () -> (number, ...any) + local world_query_iter_next + + local compatible_archetypes_u = query_archetypes(query::any) :: { Archetype } + local last_archetype_u = 1 + local archetype_u = compatible_archetypes_u[1] + if not archetype_u then + return NOOP :: () -> (number, ...any) + end + local entities_u = archetype_u.entities + local i_u = #entities_u + local columns_map_u = archetype_u.columns_map + + local ids_u = query.ids + local id0, id1, id2, id3, id4, id5, id6, id7, id8 = unpack(ids_u :: { Component }) + local col0_u: Column, col1_u: Column, col2_u: Column, col3_u: Column + local col4_u: Column, col5_u: Column, col6_u: Column, col7_u: Column + + if not id0 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + end + i_u -= 1 + return e + end + query.next = world_query_iter_next + return world_query_iter_next + elseif not id1 then + col0_u = columns_map_u[id0] + elseif not id2 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + elseif not id3 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + elseif not id4 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + elseif not id5 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + col4_u = columns_map_u[id4] + elseif not id6 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + col4_u = columns_map_u[id4] + col5_u = columns_map_u[id5] + elseif not id7 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + col4_u = columns_map_u[id4] + col5_u = columns_map_u[id5] + col6_u = columns_map_u[id6] + else + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + col4_u = columns_map_u[id4] + col5_u = columns_map_u[id5] + col6_u = columns_map_u[id6] + col7_u = columns_map_u[id7] + end + + if not id1 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col0_u = col0 + end + + local row = i_u + i_u -= 1 + + return e, col0[row] + end + elseif not id2 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col0_u = col0 + col1_u = col1 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row] + end + elseif not id3 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col0_u = col0 + col1_u = col1 + col2_u = col2 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row] + end + elseif not id4 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row] + end + elseif not id5 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row], col4[row] + end + elseif not id6 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + local col5 = col5_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col5 = columns_map[id5] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + col5_u = col5 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row] + end + elseif not id7 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + local col5 = col5_u + local col6 = col6_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col5 = columns_map[id5] + col6 = columns_map[id6] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + col5_u = col5 + col6_u = col6 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row] + end + elseif not id8 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + local col5 = col5_u + local col6 = col6_u + local col7 = col7_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col5 = columns_map[id5] + col6 = columns_map[id6] + col7 = columns_map[id7] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + col5_u = col5 + col6_u = col6 + col7_u = col7 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row] + end + else + local output = {} + local ids_len = #ids_u + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + local col5 = col5_u + local col6 = col6_u + local col7 = col7_u + local ids = ids_u + local columns_map = columns_map_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col5 = columns_map[id5] + col6 = columns_map[id6] + col7 = columns_map[id7] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + col5_u = col5 + col6_u = col6 + col7_u = col7 + end + + local row = i_u + i_u -= 1 + + for i = 9, ids_len do + output[i - 8] = columns_map[ids[i]::any][row] + end + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row], unpack(output) + end + end + + query.next = world_query_iter_next + return world_query_iter_next +end + +local function query_iter(query): () -> (number, ...any) + local query_next = query.next + if not query_next then + query_next = query_iter_init(query) + end + return query_next +end + +local function query_cached(query: QueryInner) + local ids_u = query.ids + + local id0, id1, id2, id3, id4, id5, id6, id7, id8 = unpack(ids_u :: { Component }) + if not id0 then + id0 = query.filter_with[1] + end + local col0_u: Column, col1_u: Column, col2_u: Column, col3_u: Column + local col4_u: Column, col5_u: Column, col6_u: Column, col7_u: Column + + local world_query_iter_next + local archetypes = query_archetypes(query :: any) :: { Archetype } + local archetypes_map = {} + query.archetypes_map = archetypes_map + + for j, arche in archetypes do + archetypes_map[arche.id] = j + end + + local compatible_archetypes_u = archetypes :: { Archetype } + local last_archetype_u = 1 + local archetype_u = compatible_archetypes_u[1] + local entities_u: { Entity } + local i_u: number + local columns_map_u: { [Component]: Column } + if not archetype_u then + entities_u = {} + i_u = 0 + columns_map_u = {} + else + entities_u = archetype_u.entities + i_u = #entities_u + columns_map_u = archetype_u.columns_map + end + + local world = (query :: { world: World }).world + -- Only need one observer for EcsArchetypeCreate and EcsArchetypeDelete respectively + -- because the event will be emitted for all components of that Archetype. + local observable = world.observable + local on_create_action = observable[EcsOnArchetypeCreate::any] + if not on_create_action then + on_create_action = {} :: Map + observable[EcsOnArchetypeCreate::any] = on_create_action + end + local query_cache_on_create: { Observer } = on_create_action[id0] + if not query_cache_on_create then + query_cache_on_create = {} + on_create_action[id0] = query_cache_on_create + end + + local on_delete_action = observable[EcsOnArchetypeDelete::any] + if not on_delete_action then + on_delete_action = {} :: Map + observable[EcsOnArchetypeDelete::any] = on_delete_action + end + local query_cache_on_delete: { Observer } = on_delete_action[id0] + if not query_cache_on_delete then + query_cache_on_delete = {} + on_delete_action[id0] = query_cache_on_delete + end + + local function on_create_callback(archetype: Archetype) + local n = #archetypes + 1 + archetypes[n] = archetype + archetypes_map[archetype.id] = n + end + + local function on_delete_callback(archetype) + local n = #archetypes + local lastarchetype = archetypes[n] + local archetypeid = archetype.id + local i = archetypes_map[archetypeid] + + archetypes[i] = lastarchetype + archetypes[n] = nil + + archetypes_map[archetypeid] = nil + archetypes_map[lastarchetype.id] = i + end + + local observer_for_create = { query = query, callback = on_create_callback } :: Observer + local observer_for_delete = { query = query, callback = on_delete_callback } :: Observer + + table.insert(query_cache_on_create, observer_for_create) + table.insert(query_cache_on_delete, observer_for_delete) + + local function cached_query_iter() + last_archetype_u = 1 + local compatible_archetypes = compatible_archetypes_u + archetype_u = compatible_archetypes[last_archetype_u] + if not archetype_u then + return NOOP + end + entities_u = archetype_u.entities + i_u = #entities_u + columns_map_u = archetype_u.columns_map + if not id0 then + elseif not id1 then + col0_u = columns_map_u[id0] + elseif not id2 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + elseif not id3 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + elseif not id4 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + elseif not id5 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + col4_u = columns_map_u[id4] + elseif not id6 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + col4_u = columns_map_u[id4] + col5_u = columns_map_u[id5] + elseif not id7 then + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + col4_u = columns_map_u[id4] + col5_u = columns_map_u[id5] + col6_u = columns_map_u[id6] + else + col0_u = columns_map_u[id0] + col1_u = columns_map_u[id1] + col2_u = columns_map_u[id2] + col3_u = columns_map_u[id3] + col4_u = columns_map_u[id4] + col5_u = columns_map_u[id5] + col6_u = columns_map_u[id6] + col7_u = columns_map_u[id7] + end + + return world_query_iter_next + end + + if not id0 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + end + i_u -= 1 + return e + end + elseif not id1 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col0_u = col0 + end + + local row = i_u + i_u -= 1 + + return e, col0[row] + end + elseif not id2 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col0_u = col0 + col1_u = col1 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row] + end + elseif not id3 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col0_u = col0 + col1_u = col1 + col2_u = col2 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row] + end + elseif not id4 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row] + end + elseif not id5 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row], col4[row] + end + elseif not id6 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + local col5 = col5_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col5 = columns_map[id5] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + col5_u = col5 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row] + end + elseif not id7 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + local col5 = col5_u + local col6 = col6_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col5 = columns_map[id5] + col6 = columns_map[id6] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + col5_u = col5 + col6_u = col6 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row] + end + elseif not id8 then + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + local col5 = col5_u + local col6 = col6_u + local col7 = col7_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + local columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col5 = columns_map[id5] + col6 = columns_map[id6] + col7 = columns_map[id7] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + col5_u = col5 + col6_u = col6 + col7_u = col7 + end + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row] + end + else + local output = {} + local ids_len = #ids_u + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + local col5 = col5_u + local col6 = col6_u + local col7 = col7_u + local ids = ids_u + local columns_map = columns_map_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col5 = columns_map[id5] + col6 = columns_map[id6] + col7 = columns_map[id7] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + col5_u = col5 + col6_u = col6 + col7_u = col7 + end + + local row = i_u + i_u -= 1 + + for i = 9, ids_len do + output[i - 8] = columns_map[ids[i]::any][row] + end + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row], unpack(output) + end + end + + local eindex = world.entity_index :: entityindex + + local function cached_query_has(_, entity): boolean + local r = entity_index_try_get_fast(eindex, entity) + if not r then + return false + end + + local entityarchetype = r.archetype + if not entityarchetype then + return false + end + + return archetypes_map[entityarchetype.id] ~= nil + end + + local function cached_query_fini() + local create_pos = table.find(query_cache_on_create, observer_for_create) + if create_pos then + table.remove(query_cache_on_create, create_pos) + end + + local delete_pos = table.find(query_cache_on_delete, observer_for_delete) + if delete_pos then + table.remove(query_cache_on_delete, delete_pos) + end + + compatible_archetypes_u = nil + -- NOTE(marcus): Maybe we have to be even more aggressive with cleaning + -- things up to ensure it the memory is free`d. But since most of it are + -- references we cannot be sure that someone is holding onto them making + -- it implausible to free the memory anyways + end + + local cached_query = query :: any + cached_query.archetypes = query_archetypes + cached_query.__iter = cached_query_iter + cached_query.iter = cached_query_iter + cached_query.has = cached_query_has + cached_query.fini = cached_query_fini + setmetatable(cached_query, cached_query) + return cached_query +end + +local function query_has(query: QueryInner, entity: i53) + local world = (query::any).world :: world + local r = entity_index_try_get(world.entity_index, entity) + if not r then + return false + end + local archetype = r.archetype + if not archetype then + return false + end + + local columns_map = archetype.columns_map + for _, component in query.filter_with :: {number} do + if not columns_map[component] then + return false + end + end + + local filter_without = query.filter_without + if filter_without then + for _, component in filter_without :: {number} do + if columns_map[component] then + return false + end + end + end + + return true +end + +local Query = {} +Query.__index = Query +Query.__iter = query_iter +Query.iter = query_iter_init +Query.without = query_without +Query.with = query_with +Query.archetypes = query_archetypes +Query.cached = query_cached +Query.has = query_has + +local function world_query(world: World, ...) + local ids = { ... } + + local q = setmetatable({ + ids = ids, + filter_with = ids, + world = world, + }, Query) + + return q +end +local function world_each(world: world, id: i53): () -> i53 + local idr = world.component_index[id] + if not idr then + return NOOP :: () -> i53 + end + + local records = idr.records + local archetypes = world.archetypes + local archetype_id = next(records, nil) :: number + local archetype = archetypes[archetype_id] + if not archetype then + return NOOP :: () -> i53 + end + + local entities = archetype.entities + local row = #entities + + return function() + local entity = entities[row] + while not entity do + archetype_id = next(records, archetype_id) :: number + if not archetype_id then + return nil :: any + end + archetype = archetypes[archetype_id] + entities = archetype.entities + row = #entities + entity = entities[row] + end + row -= 1 + return entity + end +end + +local function world_children(world: world, parent: i53) + return world_each(world, ECS_PAIR(EcsChildOf, parent)) +end + +local function ecs_bulk_insert(world: world, entity: i53, ids: { i53 }, values: { any }) + local entity_index = world.entity_index + local r = entity_index_try_get(entity_index, entity) + if not r then + return + end + local from = r.archetype + local component_index = world.component_index + if not from then + local dst_types = table.clone(ids) + table.sort(dst_types) + + local to = archetype_ensure(world, dst_types) + new_entity(entity, r, to) + local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE + + for i, id in ids do + local value = values[i] + if value then + r.archetype.columns_map[id][r.row] = value + end + end + + for i, id in ids do + local cdr = component_index[id] + + local on_add = cdr.on_add + if on_add then + local value = values[i] + on_add(entity, id, value, ROOT_ARCHETYPE) + end + end + return + end + + local dst_types = table.clone(from.types) + + local emplaced: { [number]: boolean } = {} + + for i, id in ids do + local at = find_insert(dst_types :: { number }, id :: number) + if at == -1 then + emplaced[i] = true + continue + end + + emplaced[i] = false + + table.insert(dst_types, at, id) + end + + local to = archetype_ensure(world, dst_types) + + if from ~= to then + entity_move(entity_index, entity, r, to) + + for i, id in ids do + local value = values[i] :: any + + if value ~= nil then + r.archetype.columns_map[id][r.row] = value + end + end + + for i, exists in emplaced do + local value = values[i] + local id = ids[i] + local idr = component_index[id] + if exists then + local on_change = idr.on_change + if on_change then + on_change(entity, id, value, from) + end + else + local on_add = idr.on_add + if on_add then + on_add(entity, id, value, from) + end + end + end + else + for i, id in ids do + local value = values[i] :: any + local idr = component_index[id] + local on_change = idr.on_change + if on_change then + on_change(entity, id, value, from) + end + + if value ~= nil then + r.archetype.columns_map[id][r.row] = value + end + end + end +end + +local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 }) + local entity_index = world.entity_index + local r = entity_index_try_get(entity_index, entity) + if not r then + return + end + local from = r.archetype + local component_index = world.component_index + if not from then + return + end + + local remove: { [i53]: boolean } = {} + + local columns_map = from.columns_map + + for i, id in ids do + if not columns_map[id] then + continue + end + + remove[id] = true + local idr = component_index[id] + + local on_remove = idr.on_remove + if on_remove then + on_remove(entity, id) + end + end + + local to = r.archetype + if from ~= to then + from = to + end + + local dst_types = table.clone(from.types) :: { i53 } + + for id in remove do + local at = table.find(dst_types, id) + table.remove(dst_types, at) + end + + to = archetype_ensure(world, dst_types) + + if from ~= to then + entity_move(entity_index, entity, r, to) + end +end + +local function world_new(DEBUG: boolean?) + local eindex_dense_array = {} :: { i53 } + local eindex_sparse_array = {} :: { record } + + local entity_index = { + dense_array = eindex_dense_array, + sparse_array = eindex_sparse_array, + alive_count = 0, + max_id = 0, + } :: entityindex + + -- NOTE(marcus): with the way the component index is accessed, we want to + -- ensure that components range has fast access. + local component_index = table.create(EcsRest) :: Map + + local archetype_index = {} :: { [string]: archetype } + local archetypes = {} :: Map + local archetype_edges = {} :: { [number]: { [i53]: archetype } } + + local observable = {} + + type Signal = { [i53]: { Listener } } + + local signals = { + added = {} :: Signal, + changed = {} :: Signal, + removed = {} :: Signal + } + + -- We need to cache the moment the world is registered, that way + -- `world:component` will not pollute the global registration of components. + local max_component_id = ecs_max_component_id + + local world = { + archetype_edges = archetype_edges, + + component_index = component_index, + entity_index = entity_index, + ROOT_ARCHETYPE = nil :: any, + + archetypes = archetypes, + archetype_index = archetype_index, + max_archetype_id = 0, + max_component_id = ecs_max_component_id, + + observable = observable, + signals = signals, + } :: world + + + local function entity_index_new_id(entity_index: entityindex): i53 + local alive_count = entity_index.alive_count + local max_id = entity_index.max_id + + if alive_count < max_id then + alive_count += 1 + entity_index.alive_count = alive_count + local id = eindex_dense_array[alive_count] + return id + end + + local id = max_id + 1 + local range_end = entity_index.range_end + ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY) + + entity_index.max_id = id + alive_count += 1 + entity_index.alive_count = alive_count + eindex_dense_array[alive_count] = id + eindex_sparse_array[id] = { dense = alive_count } :: record + + return id + end + + local ROOT_ARCHETYPE = archetype_create(world, {}, "") + world.ROOT_ARCHETYPE = ROOT_ARCHETYPE + + local function entity_index_try_get_any(entity: i53): record? + local r = eindex_sparse_array[ECS_ENTITY_T_LO(entity)] + return r + end + + local function inner_archetype_move( + entity: i53, + to: archetype, + dst_row: i24, + from: archetype, + src_row: i24 + ) + local src_columns = from.columns + local dst_entities = to.entities + local src_entities = from.entities + + local last = #src_entities + local id_types = from.types + local columns_map = to.columns_map + + if src_row ~= last then + for i, column in src_columns do + if column == NULL_ARRAY then + continue + end + local dst_column = columns_map[id_types[i]] + + if dst_column then + dst_column[dst_row] = column[src_row] + end + + column[src_row] = column[last] + column[last] = nil + end + + local e2 = src_entities[last] + src_entities[src_row] = e2 + + local record2 = eindex_sparse_array[ECS_ENTITY_T_LO(e2)] + record2.row = src_row + else + for i, column in src_columns do + if column == NULL_ARRAY then + continue + end + -- Retrieves the new column index from the source archetype's record from each component + -- We have to do this because the columns are tightly packed and indexes may not correspond to each other. + local dst_column = columns_map[id_types[i]] + + -- Sometimes target column may not exist, e.g. when you remove a component. + if dst_column then + dst_column[dst_row] = column[src_row] + end + + column[last] = nil + end + end + src_entities[last] = nil :: any + dst_entities[dst_row] = entity + end + + local function inner_entity_move( + entity: i53, + record: record, + to: archetype + ) + local sourceRow = record.row + local from = record.archetype + local dst_row = archetype_append(entity, to) + inner_archetype_move(entity, to, dst_row, from, sourceRow) + record.archetype = to + record.row = dst_row + end + + -- local function entity_index_try_get(entity: number): Record? + -- local r = entity_index_try_get_any(entity) + -- if r then + -- local r_dense = r.dense + -- if r_dense > entity_index.alive_count then + -- return nil + -- end + -- if eindex_dense_array[r_dense] ~= entity then + -- return nil + -- end + -- end + -- return r + -- end + + local function entity_index_try_get_unsafe(entity: i53): record? + local eindex_sparse_array = eindex_sparse_array + local eindex_dense_array = eindex_dense_array + local r = eindex_sparse_array[ECS_ENTITY_T_LO(entity)] + if r then + local r_dense = r.dense + -- if r_dense > entity_index.alive_count then + -- return nil + -- end + if eindex_dense_array[r_dense] ~= entity then + return nil + end + end + return r + end + + local function exclusive_traverse_add( + archetype: archetype, + cr: number, + id: i53 + ) + local edge = archetype_edges[archetype.id] + local to = edge[id] + if not to then + local dst = table.clone(archetype.types) + dst[cr] = id + to = archetype_ensure(world, dst) + edge[id] = to + end + return to + end + + local function world_set(world: world, entity: i53, id: i53, data): () + local record = entity_index_try_get_unsafe(entity) + if not record then + return + end + + local from: archetype = record.archetype + local ROOT_ARCHETYPE = ROOT_ARCHETYPE + local src = from or ROOT_ARCHETYPE + local column = src.columns_map[id] + if column then + local idr = component_index[id] + column[record.row] = data + + -- If the archetypes are the same it can avoid moving the entity + -- and just set the data directly. + local on_change = idr.on_change + if on_change then + on_change(entity, id, data, src) + end + else + local to: archetype + local idr: componentrecord + if ECS_IS_PAIR(id) then + local first = ECS_PAIR_FIRST(id) + local wc = ECS_PAIR(first, EcsWildcard) + idr = component_index[wc] + + local edge = archetype_edges[src.id] + to = edge[id] + if to == nil then + if idr and (bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) == true) then + local cr = idr.records[src.id] + if cr then + local on_remove = idr.on_remove + local id_types = src.types + if on_remove then + on_remove(entity, id_types[cr]) + src = record.archetype + id_types = src.types + cr = idr.records[src.id] + end + + to = exclusive_traverse_add(src, cr, id) + end + end + + if not to then + to = find_archetype_with(world, id, src) + if not idr then + idr = component_index[wc] + end + edge[id] = to + archetype_edges[(to :: Archetype).id][id] = src + end + else + if bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) then + local on_remove = idr.on_remove + if on_remove then + local cr = idr.records[src.id] + if cr then + local id_types = src.types + on_remove(entity, id_types[cr]) + local arche = record.archetype + if src ~= arche then + id_types = arche.types + cr = idr.records[arche.id] + to = exclusive_traverse_add(arche, cr, id) + end + end + end + end + end + else + local edges = archetype_edges + local edge = edges[src.id] + + to = edge[id] + if not to then + to = find_archetype_with(world, id, src) + edge[id] = to + edges[to.id][id] = src + end + idr = component_index[id] + end + + if from then + -- If there was a previous archetype, then the entity needs to move the archetype + inner_entity_move(entity, record, to) + else + new_entity(entity, record, to) + end + + column = to.columns_map[id] + column[record.row] = data + + local on_add = idr.on_add + if on_add then + on_add(entity, id, data, src) + end + end + end + + local function world_add( + world: world, + entity: i53, + id: i53 + ): () + local record = entity_index_try_get_unsafe(entity :: number) + if not record then + return + end + + local from = record.archetype + local ROOT_ARCHETYPE = ROOT_ARCHETYPE + local src = from or ROOT_ARCHETYPE + if src.columns_map[id] then + return + end + local to: archetype + local idr: componentrecord + + if ECS_IS_PAIR(id) then + local first = ECS_PAIR_FIRST(id) + local wc = ECS_PAIR(first, EcsWildcard) + idr = component_index[wc] + + local edge = archetype_edges[src.id] + to = edge[id] + if to == nil then + if idr and (bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) == true) then + local cr = idr.records[src.id] + if cr then + local on_remove = idr.on_remove + local id_types = src.types + if on_remove then + on_remove(entity, id_types[cr]) + + src = record.archetype + id_types = src.types + cr = idr.records[src.id] + end + + to = exclusive_traverse_add(src, cr, id) + end + end + + if not to then + to = find_archetype_with(world, id, src) + if not idr then + idr = component_index[wc] + end + edge[id] = to + archetype_edges[(to :: Archetype).id][id] = src + end + else + if bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) then + local on_remove = idr.on_remove + if on_remove then + local cr = idr.records[src.id] + if cr then + local id_types = src.types + on_remove(entity, id_types[cr]) + local arche = record.archetype + if src ~= arche then + id_types = arche.types + cr = idr.records[arche.id] + to = exclusive_traverse_add(arche, cr, id) + end + end + end + end + end + else + local edges = archetype_edges + local edge = edges[src.id] + + to = edge[id] + if not to then + to = find_archetype_with(world, id, src) + edge[id] = to + edges[to.id][id] = src + end + idr = component_index[id] + end + + if from then + inner_entity_move(entity, record, to) + else + if #to.types > 0 then + new_entity(entity, record, to) + end + end + + local on_add = idr.on_add + + if on_add then + on_add(entity, id, nil, src) + end + end + + local function world_get(world: world, entity: i53, + a: i53, b: i53?, c: i53?, d: i53?, e: i53?): ...any + local record = entity_index_try_get_unsafe(entity) + if not record then + return nil + end + + local archetype = record.archetype + if not archetype then + return nil + end + + local columns_map = archetype.columns_map + local row = record.row + + local va = fetch(a, columns_map, row) + + if not b then + return va + elseif not c then + return va, fetch(b, columns_map, row) + elseif not d then + return va, fetch(b, columns_map, row), fetch(c, columns_map, row) + elseif not e then + return va, fetch(b, columns_map, row), fetch(c, columns_map, row), fetch(d, columns_map, row) + else + error("args exceeded") + end + end + + type Listener = + & ((e: i53, id: i53, value: T, oldarchetype: archetype) -> ()) + & ((e: i53, id: i53, delete: boolean?) -> ()) + + world.added = function(_: world, component: i53, fn: Listener) + local listeners = signals.added[component] + if not listeners then + listeners = {} + signals.added[component] = listeners + + local function on_add(entity, id, value, oldarchetype) + for _, listener in listeners :: { Listener } do + listener(entity, id, value, oldarchetype) + end + end + local existing_hook = world_get(world, component, EcsOnAdd) :: Listener + if existing_hook then + table.insert(listeners, existing_hook) + end + + local idr_pair = component_index[ECS_PAIR(component, EcsWildcard)] + + if idr_pair then + for id, cr in idr_pair.wildcard_pairs do + cr.on_add = on_add + end + idr_pair.on_add = on_add + else + local idr = component_index[component] + if idr then + idr.on_add = on_add + end + end + world_set(world, component, EcsOnAdd, on_add) + end + table.insert(listeners, fn) + return function() + local n = #listeners + local i = table.find(listeners, fn) + listeners[i] = listeners[n] + listeners[n] = nil + end + end + + world.changed = function( + _: world, + component: i53, + fn: Listener + ) + local listeners = signals.changed[component] + if not listeners then + listeners = {} + signals.changed[component] = listeners + local function on_change(entity, id, value, oldarchetype) + for _, listener in listeners :: { Listener } do + listener(entity, id, value, oldarchetype) + end + end + local existing_hook = world_get(world, component, EcsOnChange) :: Listener? + if existing_hook then + table.insert(listeners, existing_hook) + end + + local idr_pair = component_index[ECS_PAIR(component, EcsWildcard)] + + if idr_pair then + for _, cr in idr_pair.wildcard_pairs do + cr.on_change = on_change + end + + idr_pair.on_change = on_change + else + local idr = component_index[component] + if idr then + idr.on_change = on_change + end + end + + world_set(world, component, EcsOnChange, on_change) + end + table.insert(listeners, fn) + return function() + local n = #listeners + local i = table.find(listeners, fn) + listeners[i] = listeners[n] + listeners[n] = nil + end + end + + world.removed = function(_: world, component: i53, fn: (i53, i53, boolean?) -> ()) + local listeners = signals.removed[component] + if not listeners then + listeners = {} + signals.removed[component] = listeners + local function on_remove(entity, id, delete) + for _, listener in listeners :: { (...any) -> () } do + listener(entity, id, delete) + end + end + + local existing_hook = world_get(world, component, EcsOnRemove) :: Listener + if existing_hook then + table.insert(listeners, existing_hook) + end + + local idr_pair = component_index[ECS_PAIR(component, EcsWildcard)] + + if idr_pair then + for _, cr in idr_pair.wildcard_pairs do + cr.on_remove = on_remove + end + + idr_pair.on_remove = on_remove + else + local idr = component_index[component] + if idr then + idr.on_remove = on_remove + end + end + + world_set(world, component, EcsOnRemove, on_remove) + end + + table.insert(listeners, fn::Listener) + + return function() + local n = #listeners + local i = table.find(listeners, fn::Listener) + listeners[i] = listeners[n] + listeners[n] = nil + end + end + + local function world_has(world: World, entity: i53, + a: i53, b: i53?, c: i53?, d: i53?, e: i53?): boolean + + local record = entity_index_try_get_unsafe(entity) + if not record then + return false + end + + local archetype = record.archetype + if not archetype then + return false + end + + local columns_map = archetype.columns_map + + return columns_map[a] ~= nil and + (b == nil or columns_map[b] ~= nil) and + (c == nil or columns_map[c] ~= nil) and + (d == nil or columns_map[d] ~= nil) and + (e == nil or error("args exceeded")) + end + + local function world_target(world: world, entity: i53, relation: i53, index: number?): i53? + local record = entity_index_try_get_unsafe(entity) + if not record then + return nil + end + + local archetype = record.archetype + if not archetype then + return nil + end + + local r = ECS_PAIR(relation, EcsWildcard) + local idr = world.component_index[r] + + if not idr then + return nil + end + + local archetype_id = archetype.id + local count = idr.counts[archetype_id] + if not count then + return nil + end + + local nth = index or 0 + + if nth >= count then + return nil + end + + nth = archetype.types[nth + idr.records[archetype_id]] + + if not nth then + return nil + end + + return entity_index_get_alive(world.entity_index, + ECS_PAIR_SECOND(nth)) + end + + local function world_parent(world: world, entity: i53): i53? + return world_target(world, entity, EcsChildOf, 0) + end + + local function world_entity(world: world, entity: i53?): i53 + if entity then + local index = ECS_ID(entity) + local alive_count = entity_index.alive_count + local r = eindex_sparse_array[index] + if r then + local dense = r.dense + + -- If dense == 0, this is a pre-populated entry from world:range() + -- Just add the entity to the end of the dense array + if dense == 0 then + alive_count += 1 + entity_index.alive_count = alive_count + r.dense = alive_count + eindex_dense_array[alive_count] = entity + return entity + end + + -- If dense > 0, check if there's an existing entity at that position + local existing_entity = eindex_dense_array[dense] + if existing_entity and existing_entity ~= entity then + alive_count += 1 + entity_index.alive_count = alive_count + r.dense = alive_count + eindex_dense_array[alive_count] = entity + return entity + end + + return entity + else + local max_id = entity_index.max_id + + if index > max_id then + -- Pre-populate all intermediate IDs to keep sparse_array as an array + for i = max_id + 1, index - 1 do + if not eindex_sparse_array[i] then + -- NOTE(marcus): We have to do this check to see if + -- they exist first because world:range() may have + -- pre-populated some slots already. + end + + eindex_sparse_array[i] = { dense = 0 } :: record + end + entity_index.max_id = index + end + + alive_count += 1 + entity_index.alive_count = alive_count + eindex_dense_array[alive_count] = entity + + r = { dense = alive_count } :: record + eindex_sparse_array[index] = r + + return entity + end + end + return entity_index_new_id(entity_index) + end + + local function world_remove(world: world, entity: i53, id: i53) + local record = entity_index_try_get_unsafe(entity) + if not record then + return + end + local from = record.archetype + + if not from then + return + end + + if from.columns_map[id] then + local idr = world.component_index[id] + local on_remove = idr.on_remove + + if on_remove then + on_remove(entity, id) + end + + local to = archetype_traverse_remove(world, id, record.archetype) + + inner_entity_move(entity, record, to) + end + end + + local function world_delete(world: world, entity: i53) + local record = entity_index_try_get_unsafe(entity) + if not record then + return + end + + local archetype = record.archetype + if archetype then + -- NOTE(marcus): It is important to remove the data and invoke + -- the hooks before the archetype and certain component records are + -- invalidated or else it will have a nasty runtime error. + for _, id in archetype.types do + local cr = component_index[id] + local on_remove = cr.on_remove + if on_remove then + on_remove(entity, id, true) + end + end + archetype_delete(world, record.archetype, record.row) + end + + local component_index = world.component_index + local archetypes = world.archetypes + local tgt = ECS_PAIR(EcsWildcard, entity) + local rel = ECS_PAIR(entity, EcsWildcard) + + local idr_t = component_index[tgt] + local idr = component_index[entity] + local idr_r = component_index[rel] + + --[[ + It is important to note that `world_delete` uses a depth-first + traversal that prunes the children of the entity before their + parents. archetypes can be destroyed and removed from component + records while we're still iterating over those records. The + recursive nature of this function entails that archetype ids can be + removed from component records (idr_t.records, idr.records and + idr_r.records) while that collection is still being iterated over. + If we try to look up an archetype by ID after it has been destroyed, + we get nil. This is hard to debug because the removal happens deep + in the opaque call stack. Essentially the entry is removed on a + first come first serve basis. + + The solution is to separate processing from cleanup. We first iterate + over the archetypes to process entities (move them, call hooks, etc.), + but do not destroy the archetypes yet. Then we iterate again to destroy + the archetypes, but check if they still exist (archetypes[archetype_id] + is not nil) before destroying. This handles the case where recursive + world_delete calls have already destroyed some archetypes. + + - Marcus + ]] + if idr then + local flags = idr.flags + if (bit32.btest(flags, ECS_ID_DELETE) == true) then + for archetype_id in idr.records do + local idr_archetype = archetypes[archetype_id] + + local entities = idr_archetype.entities + local n = #entities + for i = n, 1, -1 do + world_delete(world, entities[i]) + end + + archetype_destroy(world, idr_archetype) + end + else + local on_remove = idr.on_remove + if on_remove then + for archetype_id in idr.records do + local idr_archetype = archetypes[archetype_id] + local to = archetype_traverse_remove(world, entity, idr_archetype) + local entities = idr_archetype.entities + local n = #entities + for i = n, 1, -1 do + local e = entities[i] + on_remove(e, entity) + local r = eindex_sparse_array[ECS_ID(e :: number)] + local from = r.archetype + if from ~= idr_archetype then + -- unfortunately the on_remove hook allows a window where `e` can have changed archetype + -- this is hypothetically not that expensive of an operation anyways + to = archetype_traverse_remove(world, entity, from) + end + inner_entity_move(e, r, to) + end + + archetype_destroy(world, idr_archetype) + end + else + for archetype_id in idr.records do + local idr_archetype = archetypes[archetype_id] + local to = archetype_traverse_remove(world, entity, idr_archetype) + local entities = idr_archetype.entities + local n = #entities + for i = n, 1, -1 do + local e = entities[i] + entity_move(entity_index, e, eindex_sparse_array[ECS_ID(e :: number)], to) + end + + archetype_destroy(world, idr_archetype) + end + end + end + end + if idr_t then + local archetype_ids = idr_t.records + local to_remove = {}:: { [i53]: componentrecord} + + for archetype_id in archetype_ids do + local idr_t_archetype = archetypes[archetype_id] + local idr_t_types = idr_t_archetype.types + local entities = idr_t_archetype.entities + local deleted_any = false + local remove_count = 0 + + for _, id in idr_t_types do + if not ECS_IS_PAIR(id) then + continue + end + local object = entity_index_get_alive( + entity_index, ECS_PAIR_SECOND(id)) + if object ~= entity then + continue + end + local id_record = component_index[id] + local flags = id_record.flags + local flags_delete_mask = bit32.btest(flags, ECS_ID_DELETE) + if flags_delete_mask then + for i = #entities, 1, -1 do + local child = entities[i] + world_delete(world, child) + end + deleted_any = true + break + else + to_remove[id] = id_record + remove_count += 1 + end + end + + if deleted_any then + continue + end + + if remove_count == 1 then + local id, id_record = next(to_remove) + local to_u = archetype_traverse_remove(world, id :: i53, idr_t_archetype) + local on_remove = id_record.on_remove + for i = #entities, 1, -1 do + local child = entities[i] + local r = entity_index_try_get_unsafe(child) :: record + local to = to_u + if on_remove then + on_remove(child, id :: i53) + local src = r.archetype + if src ~= idr_t_archetype then + to = archetype_traverse_remove(world, id::i53, src) + end + end + + inner_entity_move(child, r, to) + end + elseif remove_count > 1 then + local dst_types = table.clone(idr_t_types) + for id, component_record in to_remove do + table.remove(dst_types, table.find(dst_types, id)) + end + + local to_u = archetype_ensure(world, dst_types) + for i = #entities, 1, -1 do + local child = entities[i] + local r = entity_index_try_get_unsafe(child) :: record + + local to = to_u + for id, component_record in to_remove do + local on_remove = component_record.on_remove + if on_remove then + -- NOTE(marcus): We could be smarter with this and + -- assume hooks are deterministic and that they will + -- move to the same archetype. However users often are not reasonable people. + on_remove(child, id) + local src = r.archetype + if src ~= idr_t_archetype then + to = archetype_traverse_remove(world, id, src) + end + end + end + + inner_entity_move(child, r, to) + end + end + + table.clear(to_remove) + archetype_destroy(world, idr_t_archetype) + end + end + + if idr_r then + local archetype_ids = idr_r.records + local flags = idr_r.flags + local has_delete_policy = bit32.btest(flags, ECS_ID_DELETE) + if has_delete_policy then + for archetype_id in archetype_ids do + local idr_r_archetype = archetypes[archetype_id] + local entities = idr_r_archetype.entities + local n = #entities + for i = n, 1, -1 do + world_delete(world, entities[i]) + end + archetype_destroy(world, idr_r_archetype) + end + else + local counts = idr_r.counts + local records = idr_r.records + for archetype_id in archetype_ids do + local idr_r_archetype = archetypes[archetype_id] + -- local node = idr_r_archetype + local entities = idr_r_archetype.entities + local tr = records[archetype_id] + local tr_count = counts[archetype_id] + local idr_r_types = idr_r_archetype.types + local dst = table.clone(idr_r_types) + for i = tr, tr + tr_count - 1 do + local id = idr_r_types[i] + local at = table.find(dst, id) + if at then + table.remove(dst, at) + end + -- node = archetype_traverse_remove(world, id, node) + local on_remove = component_index[id].on_remove + if on_remove then + -- NOTE(marcus): Since hooks can move the entities + -- assumptions about which archetype it jumps to is + -- diminished. We assume that people who delete + -- relation will never have hooks on them. + for _, entity in entities do + on_remove(entity, id) + end + end + end + + local node = archetype_ensure(world, dst) + + for i = #entities, 1, -1 do + local e = entities[i] + local r = entity_index_try_get_unsafe(e) :: record + inner_entity_move(e, r, node) + end + + archetype_destroy(world, idr_r_archetype) + end + end + end + + + + local dense = record.dense + local i_swap = entity_index.alive_count + entity_index.alive_count = i_swap - 1 + + local e_swap = eindex_dense_array[i_swap] + local r_swap = entity_index_try_get_any(e_swap) :: record + r_swap.dense = dense + record.archetype = nil :: any + record.row = nil :: any + record.dense = i_swap + + eindex_dense_array[dense] = e_swap + eindex_dense_array[i_swap] = ECS_GENERATION_INC(entity) + end + + local function world_clear(world: world, entity: i53) + local record = entity_index_try_get_unsafe(entity) + if not record then + return + end + + local archetype = record.archetype + for _, id in archetype.types do + local idr = component_index[id] + local on_remove = idr.on_remove + if on_remove then + on_remove(entity, id) + end + end + archetype_delete(world, record.archetype, record.row) + record.archetype = nil :: any + record.row = nil :: any + end + + local function world_exists(world: world, entity: i53): boolean + return entity_index_try_get_any(entity) ~= nil + end + + local function world_contains(world: world, entity: i53): boolean + return entity_index_is_alive(entity_index, entity) + end + + local function world_cleanup(world: world) + for _, archetype in archetypes do + if #archetype.entities == 0 then + archetype_destroy(world, archetype) + end + end + + local new_archetypes = {} + local new_archetype_map = {} + + for index, archetype in archetypes do + new_archetypes[index] = archetype + new_archetype_map[archetype.type] = archetype + end + + archetypes = new_archetypes + archetype_index = new_archetype_map + + world.archetypes = new_archetypes + world.archetype_index = new_archetype_map + end + + local function world_component(world: world): i53 + if max_component_id + 1 > HI_COMPONENT_ID then + -- IDs are partitioned into ranges because component IDs are not nominal, + -- so it needs to error when IDs intersect into the entity range. + error("Too many components, consider using world:entity() instead to create components.") + end + max_component_id += 1 + world.max_component_id = max_component_id + world_add(world, max_component_id, EcsComponent) + + return max_component_id + end + + world.entity = world_entity + world.query = world_query :: any + world.remove = world_remove + world.clear = world_clear + -- world.purge = world_purge + world.delete = world_delete + world.component = world_component + world.add = world_add + world.set = world_set + world.get = world_get :: any + world.has = world_has :: any + world.target = world_target + world.parent = world_parent + world.contains = world_contains + world.exists = world_exists + world.cleanup = world_cleanup + world.each = world_each + world.children = world_children + world.range = world_range + + if DEBUG then + -- NOTE(marcus): Make it easy to grep the debug functions and + -- being able to read the specification, without having to look + -- at the implementation to understand invariants. + + local DEBUG_DELETING_ENTITY + local function DEBUG_IS_DELETING_ENTITY(entity: i53) + if DEBUG_DELETING_ENTITY == entity then + error([[ + Tried to make structural changes while the entity is in process + of being deleted. You called this function inside of the + OnRemove hook, but the entity is going to remove all of its + components making this operation moot. + ]], 2) + end + end + + local function DEBUG_IS_INVALID_ENTITY(entity: i53) + local entity_id = ECS_ID(entity) + local r = eindex_sparse_array[entity_id] + local canonical_entity = eindex_dense_array[r.dense] + + if canonical_entity ~= entity then + error([[ + This Entity handle has an outdated generation. You are + probably holding onto an entity that you got from outside the ECS + ]], 2) + end + end + + local function DEBUG_ID_IS_INVALID(id: number) + if ECS_IS_PAIR(id) then + if ECS_ID_IS_WILDCARD(id) then + error([[ + You tried to pass in a wildcard pair. This is strictly + forbidden. You probably want to iterate the targets and + remove them one by one. You can also populate a list of + targets to remove and use jecs.bulk_remove. + ]], 2) + end + local first = ecs_pair_first(world, id) + local second = ecs_pair_second(world, id) + + assert(world:contains(first), `The first element of the pair is invalid because it is not alive in the entity index. You might be holding onto an outdated handle or may have forward declared ids via jecs.component() and jecs.tag(). In the latter case, ensure that their calls precede jecs.world() or otherwise they will not register correctly`) + assert(world:contains(second), `The second element of the pair is invalid because it is not alive in the entity index. You might be holding onto an outdated handle or may have forward declared ids via jecs.component() and jecs.tag(). In the latter case, ensure that their calls precede jecs.world() or otherwise they will not register correctly`) + else + assert(world:contains(id), `The component in your parameters is invalid because it is not alive in the entity index. You might be holding onto an outdated handle or may have forward declared ids via jecs.component() and jecs.tag(). In the latter case, ensure that their calls precede jecs.world() or otherwise they will not register correctly`) + end + end + + -- NOTE(marcus): I have to save the old function and overriding the + -- upvalue in order to actually allow cascaded deletions to also be + -- checked by our program because we use the world_delete ptr internally. + local canonical_world_delete = world_delete + local function world_delete_checked(world: world, entity: i53) + DEBUG_DELETING_ENTITY = entity + DEBUG_IS_INVALID_ENTITY(entity) + canonical_world_delete(world, entity) + DEBUG_DELETING_ENTITY = nil + end + world_delete = world_delete_checked + + local function world_remove_checked(world: world, entity: i53, id: i53) + DEBUG_IS_DELETING_ENTITY(entity) + DEBUG_IS_INVALID_ENTITY(entity) + DEBUG_ID_IS_INVALID(id) + + world_remove(world, entity, id) + end + local function world_add_checked(world: world, entity: i53, id: i53) + DEBUG_IS_DELETING_ENTITY(entity) + DEBUG_IS_INVALID_ENTITY(entity) + DEBUG_ID_IS_INVALID(id) + + world_add(world, entity, id) + end + local function world_set_checked(world: world, entity: i53, id: i53, value: any) + DEBUG_IS_DELETING_ENTITY(entity) + DEBUG_IS_INVALID_ENTITY(entity) + DEBUG_ID_IS_INVALID(id) + + world_set(world, entity, id, value) + end + world.remove = world_remove_checked + world.add = world_add_checked + world.set = world_set_checked + end + + for i = 1, EcsRest do + entity_index_new_id(entity_index) + end + + for i = 1, max_component_id do + world_add(world, i, EcsComponent) + end + + world_add(world, EcsName, EcsComponent) + world_add(world, EcsOnChange, EcsComponent) + world_add(world, EcsOnAdd, EcsComponent) + world_add(world, EcsOnRemove, EcsComponent) + world_add(world, EcsWildcard, EcsComponent) + world_add(world, EcsRest, EcsComponent) + + world_set(world, EcsOnAdd, EcsName, "jecs.OnAdd") + world_set(world, EcsOnRemove, EcsName, "jecs.OnRemove") + world_set(world, EcsOnChange, EcsName, "jecs.OnChange") + world_set(world, EcsWildcard, EcsName, "jecs.Wildcard") + world_set(world, EcsChildOf, EcsName, "jecs.ChildOf") + world_set(world, EcsComponent, EcsName, "jecs.Component") + + world_set(world, EcsOnDelete, EcsName, "jecs.OnDelete") + world_set(world, EcsOnDeleteTarget, EcsName, "jecs.OnDeleteTarget") + + world_set(world, EcsDelete, EcsName, "jecs.Delete") + world_set(world, EcsRemove, EcsName, "jecs.Remove") + world_set(world, EcsName, EcsName, "jecs.Name") + world_set(world, EcsRest, EcsRest, "jecs.Rest") + + world_add(world, EcsChildOf, ECS_PAIR(EcsOnDeleteTarget, EcsDelete)) + world_add(world, EcsChildOf, EcsExclusive) + + world_add(world, EcsOnDelete, EcsExclusive) + world_add(world, EcsOnDeleteTarget, EcsExclusive) + + for i = EcsRest + 1, ecs_max_tag_id do + entity_index_new_id(entity_index) + end + + for i, bundle in ecs_metadata do + for ty, value in bundle do + if value == NULL then + world_add(world, i, ty) + else + world_set(world, i, ty, value) + end + end + end + + return world +end + +local function ecs_is_tag(world: world, entity: i53): boolean + if ECS_IS_PAIR(entity) then + return ecs_is_tag(world, ecs_pair_first(world, entity)) or ecs_is_tag(world, ecs_pair_second(world, entity)) + end + local idr = world.component_index[entity] + if idr then + return bit32.btest(idr.flags, ECS_ID_IS_TAG) + end + return not WORLD_HAS(world, entity, EcsComponent) +end + +local function ecs_entity_record(world: world, entity: i53) + return entity_index_try_get(world.entity_index, entity) +end + +local function entity_index_ensure(entity_index: entityindex, e: i53) + local eindex_sparse_array = entity_index.sparse_array + local eindex_dense_array = entity_index.dense_array + local index = ECS_ID(e) + local alive_count = entity_index.alive_count + local r = eindex_sparse_array[index] + if r then + local dense = r.dense + + if dense == 0 then + alive_count += 1 + entity_index.alive_count = alive_count + r.dense = alive_count + eindex_dense_array[alive_count] = e + return e + end + + -- If dense > 0, check if there's an existing entity at that position + local existing_entity = eindex_dense_array[dense] + if existing_entity and existing_entity ~= e then + alive_count += 1 + entity_index.alive_count = alive_count + r.dense = alive_count + eindex_dense_array[alive_count] = e + return e + end + + return e + else + local max_id = entity_index.max_id + + if index > max_id then + for i = max_id + 1, index - 1 do + if not eindex_sparse_array[i] then + -- NOTE(marcus): We have to do this check to see if + -- they exist first because world:range() may have + -- pre-populated some slots already. + end + + eindex_sparse_array[i] = { dense = 0 } :: record + end + entity_index.max_id = index + end + + alive_count += 1 + entity_index.alive_count = alive_count + eindex_dense_array[alive_count] = e + + r = { dense = alive_count } :: record + eindex_sparse_array[index] = r + + return e + end +end + +local function new(world: world) + local e = ENTITY_INDEX_NEW_ID(world.entity_index) + return e +end + +local function new_low_id(world: world) + local entity_index = world.entity_index + + local e = 0 + if world.max_component_id < HI_COMPONENT_ID then + while true do + world.max_component_id += 1 + e = world.max_component_id + if not (entity_index_try_get_any(entity_index, e) ~= nil and e <= HI_COMPONENT_ID) then + break + end + end + end + if e == 0 or e >= HI_COMPONENT_ID then + e = ENTITY_INDEX_NEW_ID(entity_index) + else + entity_index_ensure(entity_index, e) + end + return e +end + +local function new_w_id(world: world, id: i53) + local e = ENTITY_INDEX_NEW_ID(world.entity_index) + world.add(world, e, id) + return e +end + +return { + new = new, + new_w_id = new_w_id, + new_low_id = new_low_id, + + --- Create the world + world = world_new :: (boolean?) -> World, + World = { + new = world_new + }, + --- Create a preregistered ID (see more how_to/011_preregistering_components.luau) + component = (ECS_COMPONENT :: any) :: () -> Entity, + tag = (ECS_TAG :: any) :: () -> Entity, + --- Give the preregistered ID some data (see more how_to/011_preregistering_components.luau) + meta = (ECS_META :: any) :: (id: Entity, id: Component, value: a?) -> Entity, + --- Check if the ID is a tag + is_tag = (ecs_is_tag :: any) :: (World, Component) -> boolean, + + --- OnAdd Hook to detect added components (see more how_to/110_hooks.luau) + OnAdd = (EcsOnAdd :: any) :: Component<(entity: Entity, id: Id, data: T) -> ()>, + --- OnRemove Hook to detect removed components (see more how_to/110_hooks.luau) + OnRemove = (EcsOnRemove :: any) :: Component<(entity: Entity, id: Id, delete: boolean?) -> ()>, + --- OnChange Hook to detect mutations (see more how_to/110_hooks.luau) + OnChange = (EcsOnChange :: any) :: Component<(entity: Entity, id: Id, data: T) -> ()>, + --- Relationship to define the parent of an entity + ChildOf = (EcsChildOf :: any) :: Entity, + --- This marks an ID as a component and can therefore have data (see more how_to/010_how_components_work.luau) + Component = (EcsComponent :: any) :: Entity, + --- Used for querying relationships of any target (see more how_to/041_entity_relationships.luau) + Wildcard = (EcsWildcard :: any) :: Component, + --- Alias for jecs.Wildcard + w = (EcsWildcard :: any) :: Component, + --- OnDelete Cleanup condition (see more 100_cleanup_traits) + OnDelete = (EcsOnDelete :: any) :: Entity, + --- OnDeleteTarget Cleanup condition (see more 100_cleanup_traits) + OnDeleteTarget = (EcsOnDeleteTarget :: any) :: Entity, + --- Delete Cleanup action (see more 100_cleanup_traits) + Delete = (EcsDelete :: any) :: Entity, + --- Remove Cleanup action (see more 100_cleanup_traits) + Remove = (EcsRemove :: any) :: Entity, + --- This can be used to name your components with, either as some discriminant or for debugging purposes + Name = (EcsName :: any) :: Component, + --- Used to mark whether a relationship is exclusive or not (see more 041_entity_relationships.luau) + Exclusive = (EcsExclusive :: any) :: Entity, + ArchetypeCreate = (EcsOnArchetypeCreate :: any) :: Entity, + ArchetypeDelete = (EcsOnArchetypeDelete :: any) :: Entity, + Rest = (EcsRest :: any) :: Entity, + + --- Create a pair between two components + pair = ECS_PAIR :: (first: Entity

, second: Entity) -> Pair, + + --- To check if a component is a pair + IS_PAIR = ECS_IS_PAIR :: (pair: Component) -> boolean, + ECS_PAIR_FIRST = ECS_PAIR_FIRST :: (pair: Id

) -> Component

, + ECS_PAIR_SECOND = ECS_PAIR_SECOND :: (pair: Id

) -> Component, + --- Lookup the first element in a pair + pair_first = ecs_pair_first :: (world: World, pair: Id

) -> Component

, + --- Lookup the second element in a pair + pair_second = ecs_pair_second :: (world: World, pair: Id

) -> Component, + entity_index_get_alive = entity_index_get_alive, + + archetype_append_to_records = archetype_append_to_records, + id_record_ensure = id_record_ensure :: (World, Component) -> ComponentRecord, + --- Grabs metadata on the component + component_record = id_record_get :: (World, Component) -> ComponentRecord?, + --- Grabs metadata on the entity + record = ecs_entity_record :: (World, Entity) -> Record, + + archetype_create = archetype_create :: (World, { Component }, string) -> Archetype, + archetype_ensure = archetype_ensure :: (World, { Component }) -> Archetype, + find_insert = find_insert, + find_archetype_with = find_archetype_with :: (World, Component, Archetype) -> Archetype, + find_archetype_without = find_archetype_without :: (World, Component, Archetype) -> Archetype, + create_edge_for_remove = create_edge_for_remove, + archetype_traverse_add = archetype_traverse_add :: (World, Component, Archetype) -> Archetype, + archetype_traverse_remove = archetype_traverse_remove :: (World, Component, Archetype) -> Archetype, + --- For bulk inserting components to an entity + bulk_insert = ecs_bulk_insert :: (World, Entity, { Component }, { any }) -> (), + --- For bulk removing components from an entity + bulk_remove = ecs_bulk_remove :: (World, Entity, { Component }) -> (), + + entity_move = entity_move :: (EntityIndex, Entity, Record, Archetype) -> (), + + entity_index_try_get = entity_index_try_get :: (EntityIndex, Entity) -> Record?, + entity_index_try_get_fast = entity_index_try_get_fast :: (EntityIndex, Entity) -> Record?, + entity_index_try_get_any = entity_index_try_get_any :: (EntityIndex, Entity) -> Record, + entity_index_is_alive = entity_index_is_alive :: (EntityIndex, Entity) -> boolean, + entity_index_new_id = ENTITY_INDEX_NEW_ID :: (EntityIndex) -> Entity, + entity_index_ensure = entity_index_ensure, + + Query = Query, + + query_iter = query_iter, + query_iter_init = query_iter_init, + query_with = query_with, + query_without = query_without, + query_archetypes = query_archetypes, + query_match = query_match, + + find_observers = find_observers :: (World, Component, Component) -> { Observer }, + + ECS_ID = ECS_ENTITY_T_LO :: (Entity) -> number, + ECS_GENERATION_INC = ECS_GENERATION_INC :: (Entity) -> Entity, + ECS_GENERATION = ECS_GENERATION :: (Entity) -> number, + ECS_ID_IS_WILDCARD = ECS_ID_IS_WILDCARD, + ECS_ID_IS_EXCLUSIVE = ECS_ID_IS_EXCLUSIVE, + ECS_ID_DELETE = ECS_ID_DELETE, + ECS_META_RESET = ECS_META_RESET, + ECS_COMBINE = ECS_COMBINE :: (number, number) -> Entity, + ECS_ENTITY_MASK = ECS_ENTITY_MASK, +} diff --git a/modules/Jabby/module.luau b/modules/Jabby/module.luau new file mode 100644 index 0000000..ff03988 --- /dev/null +++ b/modules/Jabby/module.luau @@ -0,0 +1,60 @@ +local jecs = require(script.Parent.jecs) +local traffic_check = require(script.Parent.modules.traffic_check) +local types = require(script.Parent.modules.types) +local vm_id = require(script.Parent.modules.vm_id) +local server = require(script.Parent.server) +local public = require(script.Parent.server.public) +local scheduler = require(script.Parent.server.scheduler) + +type Applet = { + add_to_public: (name: string, config: T) -> () +} + +local world_applet = { + add_to_public = function( + name: string, config: { world: jecs.World, entities: {[Instance]: jecs.Entity}?, get_entity_from_part: ((part: BasePart) -> (jecs.Entity, Part?))? } + ) + public.updated = true + table.insert(public, { + class_name = "World", + name = name, + world = config.world, + entities = config.entities, + get_entity_from_part = config.get_entity_from_part + }) + end +} + +local scheduler_applet = { + add_to_public = function( + name: string, config: { scheduler: types.Scheduler } + ) + public.updated = true + config.scheduler.name = name + table.insert(public, config.scheduler) + end +} + +return { + set_check_function = function(callback: (Player) -> boolean) + traffic_check.can_use_jabby = callback + end, + + obtain_client = function() + return require(script.Parent.client) + end, + + vm_id = vm_id, + scheduler = scheduler, + + broadcast_server = server.broadcast, + + applets = { + world = world_applet, + scheduler = scheduler_applet + }, + + register = function(info: { name: string, applet: Applet, configuration: T }) + info.applet.add_to_public(info.name, info.configuration) + end, +} diff --git a/modules/Jabby/modules/average.luau b/modules/Jabby/modules/average.luau new file mode 100644 index 0000000..b9b89ad --- /dev/null +++ b/modules/Jabby/modules/average.luau @@ -0,0 +1,11 @@ + + +local function average(n: {number}) + local sum = 0 + for i, v in n do + sum += v + end + return sum / #n +end + +return average \ No newline at end of file diff --git a/modules/Jabby/modules/convert_units.luau b/modules/Jabby/modules/convert_units.luau new file mode 100644 index 0000000..b0fa7cf --- /dev/null +++ b/modules/Jabby/modules/convert_units.luau @@ -0,0 +1,39 @@ +local function convert_units(unit: string, value: number): (string) + local s = math.sign(value) + value = math.abs(value) + local prefixes = { + [4] = "T", + [3] ="G", + [2] ="M", + [1] = "k", + [0] = " ", + [-1] = "m", + [-2] = "u", + [-3] = "n", + [-4] = "p" + } + + local order = 0 + + while value >= 1000 do + order += 1 + value /= 1000 + end + + while value ~= 0 and value < 1 do + order -= 1 + value *= 1000 + end + + if value >= 100 then + value = math.floor(value) + elseif value >= 10 then + value = math.floor(value * 1e1) / 1e1 + elseif value >= 1 then + value = math.floor(value * 1e2) / 1e2 + end + + return value * s .. prefixes[order] .. unit +end + +return convert_units \ No newline at end of file diff --git a/modules/Jabby/modules/hash_connector.luau b/modules/Jabby/modules/hash_connector.luau new file mode 100644 index 0000000..a2de312 --- /dev/null +++ b/modules/Jabby/modules/hash_connector.luau @@ -0,0 +1,5 @@ +local types = require(script.Parent.types) + +return function(connector: types.IncomingConnector | types.OutgoingConnector) + return `{connector.host}\0{connector.from_vm or connector.to_vm}` +end \ No newline at end of file diff --git a/modules/Jabby/modules/lon.luau b/modules/Jabby/modules/lon.luau new file mode 100644 index 0000000..15930d7 --- /dev/null +++ b/modules/Jabby/modules/lon.luau @@ -0,0 +1,1044 @@ +--!strict +local function stream(input: string) + + local pos = 0 + local line = 1 + local col = 1 + + local function peek(): string + return string.sub(input, pos+1, pos+1) + end + + local function next(): string + local char = peek() + pos += 1 + + if char == "\n" then line += 1; col = 1 + else col += 1 end + + return char + end + + local function eof(): boolean + return peek() == "" + end + + local function position() + return { + pos = pos, + line = line, + col = col + } + end + + local function croak(msg) + error(`{msg} ({line}:{col})`, 0) + end + + return { + peek = peek, + next = next, + eof = eof, + croak = croak, + pos = position + } + +end + +export type String = {type: "string", s: string} +export type Number = {type: "number", s: number} +export type Identifier = {type: "identifier", s: string} +export type Keyword = {type: "keyword", s: "true" | "false" | "nil"} +export type Operator = {type: "operator", s: "="} +export type Symbol = {type: "symbol", s: "." | ":" | "[" | "]" | "{" | "}" | "(" | ")" | "," | ";"} + +export type Token = + | String + | Number + | Identifier + | Keyword + | Operator + | Symbol + +local digits = ("0123456789"):split("") +local alphabet = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"):split("") +local symbols = ("[]{}(),;.:"):split("") + +local escape_chars = { + a = "\a", + b = "\b", + f = "\f", + n = "\n", + r = "\r", + t = "\t", + v = "\v", + ["\\"] = "\\", + ["\""] = "\"", + ["\'"] = "\'" +} +local keywords = {"true", "false"} + +local function lex(source: string) + + local input = stream(source) + + local function is_whitespace(char: string): boolean + return not not string.match(char, "%s") + end + + local function is_digit(char: string): boolean + return not not table.find(digits, char) + end + + local function is_identifier(char: string): boolean + return not not table.find(alphabet, char) or not not table.find(digits, char) + end + + local function is_start_identifier(char: string): boolean + return not not table.find(alphabet, char) + end + + local function is_op_char(char: string): boolean + return char == "=" + end + + local function is_punc(char: string): boolean + return not not table.find(symbols, char) + end + + local function read_while(predicate: (char: string) -> boolean) + local str = "" + while input.eof() == false and predicate(input.peek()) do + str ..= input.next() + end + return str + end + + local function skip_comment() + read_while(function(char) return char ~= "\n" end) + end + + local function skip_whitespace() + read_while(is_whitespace) + end + + local function read_string(): String + local escaped = false + local token = "" + local eliminator = input.next() + + while input.eof() == false and (input.peek() ~= eliminator or escaped) do + local char = input.next() + + if char == "\\" then + escaped = true + end + + if escaped then + token ..= escape_chars[char] or input.croak(`cannot escape {char}`) + escaped = false + else + token ..= char + end + end + + -- print("t", token, eliminator, input.peek()) + + if input.peek() ~= eliminator then input.croak("unterminated string") end + input.next() + return {type = "string", s = token} + end + + local function read_number(): Number + local sign = 1 + if input.peek() == "-" then sign = -1; input.next() end + + local token = read_while(function(char) + return is_digit(char) or char == "." or char == "e" + end) + + local n = tonumber(token) + + if not n then + input.croak(`could not read {token} as number`) + end + + return {type = "number", s = assert(n) * sign} + end + + local function read_identifier(): Identifier | Keyword + local token = read_while(is_identifier) + + if table.find(keywords, token) then + return {type = "keyword", s = token :: any} + else + return {type = "identifier", s = token} + end + end + + local function read_next(): Token + skip_whitespace() + local char = input.peek() + + if char == "#" then + -- skip comment + skip_comment() + return read_next() + end + + if char == "\"" or char == "'" then return read_string() end + if is_digit(char) then return read_number() end + if is_start_identifier(char) then return read_identifier() end + if is_op_char(char) then return {type = "operator" :: "operator", s = input.next() :: any} end + if is_punc(char) then return {type = "symbol" :: "symbol", s = input.next() :: any} end + if char == "-" then return read_number() end + + input.croak(`cannot lex "{char}" {string.byte(char)}`) + error("fail") + end + + local current: {Token} = {} + + local function next() + local token = table.remove(current, 1) + token = if token == nil then read_next() else token + skip_whitespace() + return token + end + + local function peek(n: number?) + local n = n or 1 + while #current < n do + table.insert(current, read_next()) + end + return current[n] + end + + return { + + peek = peek, + next = next, + eof = input.eof, + croak = input.croak, + pos = input.pos + + } + +end + +type NodeNumber = { + type: "number", + value: number +} + +type NodeString = { + type: "string", + value: string +} + +type NodeBoolean = { + type: "boolean", + value: boolean +} + +type NodeVariable = { + type: "variable", + value: string +} + +type NodeTable = { + type: "table", + value: {NodeExpression | NodeAssign} +} + +type NodeAssign = { + type: "assign", + key: NodeVariable | NodeExpression, + value: NodeExpression +} + +type NodeIndex = { + type: "index", + value: NodeExpression, + key: NodeExpression +} + +type NodeFunctionCall = { + type: "functioncall", + value: NodeExpression, + args: {NodeExpression} +} + +type NodeMethodCall = { + type: "methodcall", + value: NodeExpression, + args: {NodeExpression} +} + +type NodeExpression = + | NodeNumber + | NodeString + | NodeBoolean + | NodeTable + | NodeIndex + | NodeFunctionCall + | NodeMethodCall + | NodeVariable + +type Node = + | NodeNumber + | NodeString + | NodeBoolean + | NodeTable + | NodeIndex + | NodeFunctionCall + | NodeMethodCall + | NodeAssign + | NodeVariable + +local function parse(source: string) + + local stream = lex(source) + + local node: Node? + + local function set_node(t: Node) + node = t + end + + local function pop_node(): Node + local stored = node + node = nil + if stored == nil then + stream.croak("no available node") + error("") + end + return stored + end + + local function is_symbol(s: string) + local node = stream.peek() + return node.type == "symbol" and node.s == s + end + + local function is_op(s: string) + local node = stream.peek() + return node.type == "operator" and node.s == s + end + + local function is_keyword(s: string) + local node = stream.peek() + return node.type == "keyword" and node.s == s + end + + local function skip_symbol(ch) + if is_symbol(ch) then + stream.next() + else + stream.croak(`expecting symbol: "{ch}" got "{stream.peek(1).s}"`) + error("") + end + end + + local function skip_operator(ch) + if is_op(ch) then + stream.next() + else + stream.croak(`expecting op: "{ch}" got "{stream.peek().s}"`) + error("") + end + end + + local function delimited(start, stop, separator: string | () -> (), parser: () -> ()) + local a = {} + local first = true + skip_symbol(start) + while not stream.eof() do + if is_symbol(stop) then break end + if first then + first = false + elseif typeof(separator) == "string" then + skip_symbol(separator) + elseif typeof(separator) == "function" then + separator() + end + if is_symbol(stop) then break end + parser() + table.insert(a, pop_node()) + -- print('next') + end + skip_symbol(stop) + return a + end + + local parse_functioncall + local parse_identifier + local parse_expression + local parse_table + local parse_index + + function parse_table() + local values = delimited("{", "}", ",", function() + + if is_symbol("[") then + skip_symbol("[") + parse_expression() + skip_symbol("]") + + local key = pop_node() :: NodeExpression + + skip_operator("=") + parse_expression() + + local value = pop_node() :: NodeExpression + + set_node({ + type = "assign", + key = key, + value = value + } :: NodeAssign) + else + if stream.peek(2).type == "operator" and stream.peek(2).s == "=" then + parse_identifier "string" + + local key = pop_node() :: NodeString + + skip_operator("=") + + parse_expression() + + local value = pop_node() :: NodeExpression + + set_node ({ + type = "assign", + key = key, + value = value + } :: NodeAssign) + + else + parse_expression() + end + end + + end) + + set_node { + type = "table", + value = values + } + end + + function parse_functioncall(is_method: boolean) + local value = pop_node() + local arguments = delimited("(", ")", ",", function() + parse_expression() + end) + + set_node ({ + type = if is_method then "methodcall" else "functioncall", + value = value, + args = arguments + } :: NodeFunctionCall | NodeFunctionCall) + end + + function parse_index() + if is_symbol(".") then + local value = pop_node() :: NodeExpression + + skip_symbol(".") + parse_identifier("string") + + local key = pop_node() :: NodeString + + set_node { + type = "index", + value = value, + key = key + } + elseif is_symbol("[") then + local value = pop_node() :: NodeExpression + + skip_symbol("[") + parse_expression() + skip_symbol("]") + + local key = pop_node() :: NodeExpression + + set_node { + type = "index", + value = value, + key = key + } + elseif is_symbol(":") then + local value = pop_node() :: NodeExpression + + skip_symbol(":") + parse_identifier("string") + + local key = pop_node() :: NodeString + + set_node { + type = "index", + value = value, + key = key + } + + parse_functioncall(true) + else + stream.croak(`expected index or function call, got {stream.peek().s}`) + end + end + + function parse_identifier(as: "string" | "variable") + local token = stream.next() + if token == nil then + stream.croak("expected identifier, got eof") + error("") + end + + if token.type == "identifier" then + set_node({type = as, value = token.s} :: NodeVariable | NodeString) + else + stream.croak(`expected identifier, got {token.type}`) + end + end + + function parse_expression() + local token = stream.peek() + + if token.type == "identifier" then + parse_identifier "variable" + + while + stream.eof() == false and + ( + is_symbol(".") or + is_symbol("[") or + is_symbol("(") or is_symbol(":") + ) + do + if stream.eof() then break end + if is_symbol("(") then + parse_functioncall(false) + else + parse_index() + end + if stream.eof() then break end + end + elseif token.type == "string" then + set_node {type = "string", value = assert(stream.next()).s :: string} + elseif token.type == "number" then + set_node {type = "number", value = assert(stream.next()).s :: number} + elseif is_keyword("true") then + set_node {type = "boolean", value = true} + stream.next() + elseif is_keyword("false") then + set_node {type = "boolean", value = false} + stream.next() + elseif is_symbol("{") then + parse_table() + else + stream.croak(`expected expression, got {token.type}" "{token.s}"`) + end + + end + + parse_expression() + + return pop_node() + +end + +local tuple = {} + +local lon_vars = { + + Vector3 = Vector3, + CFrame = CFrame, + Vector2 = Vector2, + Color3 = Color3, + BrickColor = BrickColor, + Enum = Enum, + + NumberSequence = NumberSequence, + NumberSequenceKeypoint = NumberSequenceKeypoint, + NumberRange = NumberRange, + + ColorSequence = ColorSequence, + ColorSequenceKeypoint = ColorSequenceKeypoint, + + Region3 = Region3, + Rect = Rect, + + OverlapParams = function(properties) + local params = OverlapParams.new() + for key, value in properties do + (params :: any)[key] = value + end + return params + end, + RaycastParams = function(properties) + local params = RaycastParams.new() + for key, value in properties do + (params :: any)[key] = value + end + return params + end, + + game = game, + workspace = workspace + +} + +local function compile(node: Node): any + + if node.type == "number" then + return node.value + elseif node.type == "string" then + return node.value + elseif node.type == "boolean" then + return node.value + elseif node.type == "variable" then + return lon_vars[node.value] + elseif node.type == "index" then + return compile(node.value)[compile(node.key)] + elseif node.type == "functioncall" then + for i = 1, #node.args do + tuple[i] = compile(node.args[i]) + end + return compile(node.value)(unpack(tuple, 1, #node.args)) + elseif node.type == "methodcall" then + for i = 1, #node.args do + tuple[i] = compile(node.args[i]) + end + local data = compile(node.value) + return data(data, unpack(tuple, 1, #node.args)) + elseif node.type == "table" then + local t = {} + local i = 0 + + for _, value in node.value do + if value.type == "assign" then + t[compile(value.key)] = compile(value.value) + else + t[i + 1] = compile(value); i += 1 + end + end + + return t + elseif node.type == "assign" then + error("bad ast, you cannot generate this") + end + + return nil +end + +local output + +do + local max_size = 8192 + local buf = buffer.create(1024) + local pos = 0 + local tbs = 0 + local enable_newlines = true + local line_length = 0 + + local temp_input = buffer.create(1024) + + local cyclic_buffer = {} + + local function prealloc(bytes: number) + local old_size = buffer.len(buf) + + if pos + bytes >= old_size then + local new_size = old_size + old_size / 2 + while pos + bytes >= new_size do + new_size = new_size + new_size / 2 + end + + local new_buf = buffer.create(new_size) + buffer.copy(new_buf, 0, buf, 0, pos) + buf = new_buf + end + end + + local function write_str(str: string) + local old_size = buffer.len(buf) + + if pos + #str >= old_size then + local new_size = old_size + old_size / 2 + while pos + #str >= new_size do + new_size = new_size + new_size / 2 + end + + local new_buf = buffer.create(new_size) + buffer.copy(new_buf, 0, buf, 0, pos) + buf = new_buf + end + + buffer.writestring(buf, pos, str, #str) + pos += #str + line_length += #str + end + + local function write_low_prec_float(n: number) + local s = string.format("%.7g", n) + write_str(s) + line_length += #s + end + + local function char(c: string): number + return (string.byte(c)) + end + + local function write_char(char: number) + local old_size = buffer.len(buf) + + if pos + 1 >= old_size then + local new_size = old_size + old_size / 2 + while pos + 1 >= new_size do + new_size = new_size + new_size / 2 + end + + local new_buf = buffer.create(new_size) + buffer.copy(new_buf, 0, buf, 0, pos) + buf = new_buf + end + + buffer.writeu8(buf, pos, char) + line_length += 1 + pos += 1 + end + + export type Displayable = unknown-- number | string | boolean | { [Displayable]: Displayable } + + local function display_string(value: string) + local f = string.format("%q", value) + prealloc(#f) + write_str(f) + end + + local function write_line() + if enable_newlines then + write_char(char("\n")) + write_str(string.rep("\t", tbs)) + else + write_char(char(" ")) + end + end + + local display + local fatal_if_bad = false + + local function to_path(new: boolean, ...: unknown) + local original = fatal_if_bad + fatal_if_bad = true + + if new then + local input = {} + local len = select("#", ...) + + local old_pos = pos + local old_tbs = tbs + local old_buf = buf + buf = temp_input + + for i = len, 2, -1 do + pos = 0 + tbs = 0 + + local d = len - i + display(select(i, ...)) + + input[d] = buffer.readstring(temp_input, 0, pos) + end + + pos = old_pos + tbs = old_tbs + buf = old_buf + + fatal_if_bad = original + return `new({table.concat(input, ", ")})` + else + local input = {} + local len = select("#", ...) + + local old_pos = pos + local old_tbs = tbs + local old_buf = buf + buf = temp_input + + for i = len, 1, -1 do + pos = 0 + tbs = 0 + + local d = len - i + display(select(i, ...)) + input[d + 1] = `[{buffer.readstring(temp_input, 0, pos)}]` + end + + pos = old_pos + tbs = old_tbs + buf = old_buf + table.insert(input, 1, "old") + + fatal_if_bad = original + return table.concat(input) + end + end + + function display(value: Displayable, ...: unknown) + if max_size < pos then return end + if type(value) == "number" then + write_str(tostring(value)) + elseif type(value) == "string" then + display_string(value) + elseif type(value) == "boolean" then + if value == true then + write_str("true") + else + write_str("false") + end + elseif type(value) == "table" and not cyclic_buffer[value] then + cyclic_buffer[value] = true + tbs += 1 + write_char(char("{")) + for index, value in value :: {[any]: any} do + write_line() + + if type(index) == "string" and string.match(index, "^[a-zA-Z_][a-zA-Z0-9_]*$") then + write_str(index) + else + write_char(char("[")) + local original = fatal_if_bad + fatal_if_bad = true + display(index) + fatal_if_bad = original + write_char(char("]")) + end + + write_char(char(" ")) + write_char(char("=")) + write_char(char(" ")) + + display(value, index, ...) + write_char(char(",")) + end + + tbs -= 1 + write_line() + write_char(char("}")) + elseif type(value) == "table" and cyclic_buffer[value] then + if fatal_if_bad then + write_str("*cannot display table due to cyclic dependency*") + else + write_str(to_path(true, ...)) + end + elseif type(value) == "nil" then + write_str("nil") + -- ROBLOX DEVIATION START + elseif typeof(value) == "BrickColor" then + write_str("BrickColor.new(") + write_str(value.Name) + write_char(char(")")) + elseif typeof(value) == "CFrame" then + if value.Rotation == CFrame.new() then + write_str("CFrame.new(") + display(value.X) + write_str(", ") + display(value.Y) + write_str(", ") + display(value.Z) + write_char(char(")")) + else + write_str("CFrame.fromMatrix(") + display(value.Position) + write_str(",\n") + display(value.XVector) + write_str(",\n") + display(value.YVector) + write_str(",\n") + display(value.ZVector) + write_str(",\n") + end + elseif typeof(value) == "EnumItem" then + write_str(tostring(value)) + elseif typeof(value) == "Enum" then + write_str("Enum.") + write_str(tostring(value)) + elseif typeof(value) == "Enums" then + write_str("Enum") + elseif typeof(value) == "Color3" then + write_str("Color3.fromHex(") + display_string(value:ToHex()) + write_char(char(")")) + elseif typeof(value) == "Vector3" then + write_str("Vector3.new(") + write_str(tostring(value)) + write_char(char(")")) + elseif typeof(value) == "Vector2" then + write_str("Vector2.new(") + write_str(tostring(value)) + write_char(char(")")) + elseif typeof(value) == "NumberSequence" then + write_str("NumberSequence.new(") + if #value.Keypoints == 2 then + write_low_prec_float(value.Keypoints[1].Value) + write_str(", ") + write_low_prec_float(value.Keypoints[2].Value) + else + display(value.Keypoints) + end + write_char(char(")")) + elseif typeof(value) == "NumberSequenceKeypoint" then + write_str("NumberSequenceKeypoint.new(") + write_low_prec_float(value.Time) + write_str(", ") + write_low_prec_float(value.Value) + if value.Envelope > 0 then + write_str(", ") + write_low_prec_float(value.Envelope) + end + write_char(char(")")) + elseif typeof(value) == "NumberRange" then + write_str("NumberRange.new(") + write_low_prec_float(value.Min) + if value.Min ~= value.Max then + write_str(", ") + write_low_prec_float(value.Max) + end + write_char(char(")")) + elseif typeof(value) == "ColorSequence" then + write_str("ColorSequence.new(") + if #value.Keypoints == 2 then + display(value.Keypoints[1].Value) + write_str(", ") + display(value.Keypoints[2].Value) + else + display(value.Keypoints) + end + write_char(char(")")) + elseif typeof(value) == "ColorSequenceKeypoint" then + write_str("ColorSequenceKeypoint.new(") + write_low_prec_float(value.Time) + write_str(", ") + display(value.Value) + write_char(char(")")) + elseif typeof(value) == "RaycastResult" then + --note: since a RaycastResult can't be created, we convert it into a table + display { + Instance = value.Instance, + Material = value.Material, + Normal = value.Normal, + Position = value.Position, + Distance = value.Distance + } + elseif typeof(value) == "Region3" then + local pos, size = value.CFrame.Position, value.Size + local min = pos - size / 2 + local max = pos + size / 2 + write_str("Region3.new(") + display(min) + write_str(", ") + display(max) + write_char(char(")")) + elseif typeof(value) == "Rect" then + write_str("Rect.new(") + write_low_prec_float(value.Min.X) + write_str(", ") + write_low_prec_float(value.Min.Y) + write_str(", ") + write_low_prec_float(value.Max.X) + write_str(", ") + write_low_prec_float(value.Max.Y) + write_char(char(")")) + elseif typeof(value) == "OverlapParams" then + write_str("OverlapParams(") + display({ + CollisionGroup = value.CollisionGroup, + FilterType = value.FilterType, + MaxParts = value.MaxParts, + RespectCanCollide = value.RespectCanCollide, + }) + write_char(char(")")) + elseif typeof(value) == "RaycastParams" then + write_str("RaycastParams(") + display({ + CollisionGroup = value.CollisionGroup, + FilterType = value.FilterType, + RespectCanCollide = value.RespectCanCollide, + }) + write_char(char(")")) + elseif typeof(value) == "Instance" and not fatal_if_bad then + write_str(to_path(false, ...)) + elseif typeof(value) == "Instance" and fatal_if_bad then + write_str(value:GetFullName()) + -- ROBLOX DEVIATION END + else + write_str(to_path(false, ...)) + end + end + + output = function(value: Displayable, newlines: boolean?, preview: boolean?) + enable_newlines = if newlines ~= nil then newlines else true + + pos = 0 + tbs = 0 + + table.clear(cyclic_buffer) + if preview then fatal_if_bad = true end + display(value) + fatal_if_bad = false + return buffer.readstring(buf, 0, pos) + end +end + +return { + + parse = parse, + compile = function(node: Node, variables: {[string]: any}) + setmetatable(lon_vars, {__index = variables or nil}) + local mt = { symbol = "new" } + local should_recurse = false + + lon_vars.new = function(...) + should_recurse = true + local symbol = setmetatable({ we_left_something = true, ... }, mt) + return symbol + end + + local nodes = compile(node) + + local function find(input: { unknown }) + local v = nodes + for _, key in ipairs(input) do + v = v[key] + if v == nil then return nil end + end + return v + end + + -- recurse through nodes + local checked = {} + local function recurse(value: unknown) + for k, v in pairs(value) do -- use pairs so we explicitly iterate over keys and values + if typeof(v) == "table" and getmetatable(v) == mt then + value[k] = find(v) + elseif typeof(v) == "table" and checked[v] then + continue + elseif typeof(v) == "table" then + checked[v] = true + recurse(v) + end + end + end + + if should_recurse and typeof(nodes) == "table" then + recurse(nodes) + end + + return nodes + end, + output = output + +} \ No newline at end of file diff --git a/modules/Jabby/modules/loop.luau b/modules/Jabby/modules/loop.luau new file mode 100644 index 0000000..f57d358 --- /dev/null +++ b/modules/Jabby/modules/loop.luau @@ -0,0 +1,107 @@ +local scheduler = require(script.Parent.Parent.server.scheduler) +type Array = { T } + +export type System = (any, number) -> ...(any, number) -> () + +type GroupInfo = { i: number?, o: number? } + +type SystemGroup = { + interval: number, + offset: number, + dt: number, + [number]: { + id: number, + name: string, + type: number, + fn: (...any) -> ...any + } +} + +local function loop_create(name: string, data: any, ...: ModuleScript | () -> () | GroupInfo) + local jabby_scheduler = scheduler.create(name) + + local groups = {} :: Array + + local current_group: SystemGroup? + + local function process_systems(array: Array) + for i, v in array do + if type(v) == "table" then + if v.i then + if current_group then + table.insert(groups, current_group) + end + + current_group = { + interval = v.i or 1, + offset = v.o or 0, + dt = 0 + } + else + process_systems(v) + end + elseif type(v) == "function" then + assert(current_group) + + table.insert(current_group, { + id = jabby_scheduler:register_system(), + name = "UNNAMED", + type = 0, + fn = v + }) + else + assert(current_group) + + local fn = (require :: any)(v) :: System + local fn2 = fn(data, 0) + + table.insert(current_group, { + id = jabby_scheduler:register_system({name = `{v.Name}`}), + name = v.Name, + type = fn2 and 1 or 0, + fn = fn2 or fn + }) + end + end + end + + process_systems { ... } + + assert(current_group) + table.insert(groups, current_group) + current_group = nil + + local frame_count = 0 + + return function(dt) + frame_count += 1 + + debug.profilebegin("ECS LOOP") + + for _, group in groups do + group.dt += dt + + if frame_count % group.interval == group.offset then + for _, system in ipairs(group) do + debug.setmemorycategory(system.name) + debug.profilebegin(system.name) + + if system.type == 0 then + jabby_scheduler:run(system.id, system.fn, data, group.dt) + else + jabby_scheduler:run(system.id, system.fn, group.dt) + end + + debug.profileend() + end + + group.dt = 0 + end + end + + debug.resetmemorycategory() + debug.profileend() + end, jabby_scheduler +end + +return loop_create \ No newline at end of file diff --git a/modules/Jabby/modules/net.luau b/modules/Jabby/modules/net.luau new file mode 100644 index 0000000..9edcb4d --- /dev/null +++ b/modules/Jabby/modules/net.luau @@ -0,0 +1,202 @@ +--[[ + + net is a utility library designed to handle connections to other actors and + the server for me. + +]] + +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +local signal = require(script.Parent.signal) +local vm_id = require(script.Parent.vm_id) +local traffic_check = require(script.Parent.traffic_check) +local types = require(script.Parent.types) + +local local_host: "server" | Player +local MANAGER_VM = 0 + +if RunService:IsServer() then + local_host = "server" +else + local_host = Players.LocalPlayer +end + +local function tincoming_connector(t: any): boolean + if typeof(t) ~= "table" then return false end + if not (t.host == "server" or (typeof(t.host) == "Instance" and t.host:IsA("Player"))) then return false end + if typeof(t.from_vm) ~= "number" then return false end + if t.to_vm ~= nil and typeof(t.to_vm) ~= "number" then return false end + + return true +end + +local NAME = "JABBY_REMOTES" +local folder: Instance +local created_folder = false +if RunService:IsServer() then + local existing = ReplicatedStorage:FindFirstChild(NAME) + if existing then + folder = existing + else + created_folder = true + warn("\z + There's a bug with jabby that sometimes causes the JABBY_REMOTES folder to not replicate very rarely. \z + Unfortunately, I still haven't thought of a fix yet -,- so please instead clone the JABBY_REMOTES \z + folder into your game which should stop it. make sure to set archivable to true!") + folder = Instance.new("Folder") + folder.Name = NAME + folder.Archivable = false + folder.Parent = ReplicatedStorage + end +else + folder = ReplicatedStorage:WaitForChild(NAME) +end + +local function get_remote_event(name: string, unreliable: boolean?): RemoteEvent & { actor: BindableEvent, peer: RemoteEvent } + if RunService:IsServer() then + return folder:FindFirstChild(name) :: RemoteEvent & { actor: BindableEvent } + or (function() + if not created_folder then + warn(`btw, you are missing {name} from the JABBY_REMOTES folder`) + end + + local remote = Instance.new(if unreliable then "UnreliableRemoteEvent" else "RemoteEvent") + remote.Name = name + remote.Parent = folder + + local fire_actor = Instance.new("BindableEvent") + fire_actor.Name = "actor" + fire_actor.Parent = remote + + local peer = Instance.new("RemoteEvent") + peer.Name = "peer" + peer.Parent = remote + + return remote :: RemoteEvent & { actor: BindableEvent, peer: RemoteEvent } + end)() + else + return folder:WaitForChild(name) :: RemoteEvent & { actor: BindableEvent } + end +end + +local function create_event(name: string, unreliable: boolean?, do_not_block_traffic: boolean?) + local remote = get_remote_event(name, unreliable) + local on_event_fire, fire = signal() + + local event = { + type = "event", + + fire = function(_, connector: types.OutgoingConnector, ...) + --- if the host is within this vm, we can fire it straight to + if not traffic_check.check(local_host, connector.host, true) then return end + + -- same host, same vm. + if + connector.host == local_host + and connector.to_vm == vm_id + then + + local incoming = { + host = local_host, + from_vm = vm_id, + to_vm = connector.to_vm + } + + fire(incoming, ...) + --- if the host is the same, but in a separate actor + --- we have to fire the actor + elseif + connector.host == local_host + and connector.to_vm ~= vm_id + then + local incoming = { + host = local_host, + from_vm = vm_id, + to_vm = connector.to_vm + } + + remote.actor:Fire(incoming, ...) + --- we need to fire the server + elseif connector.host == "server" then + local incoming = { + host = "server", + from_vm = vm_id, + to_vm = connector.to_vm + } + + remote:FireServer(incoming, ...) + --- we need to fire the client + elseif local_host == "server" then + local incoming = { + host = "server", + from_vm = vm_id, + to_vm = connector.to_vm + } + + remote:FireClient(connector.host, incoming, ...) + --- we need to tell the server to redirect this to the client + else + local incoming = { + host = connector.host, + from_vm = vm_id, + to_vm = connector.to_vm + } + + remote:FireServer(incoming, ...) + end + + end, + + connect = function(_, callback: (types.IncomingConnector, T...) -> ()) + return on_event_fire:connect(callback :: any) + end + } + + if RunService:IsServer() then + remote.OnServerEvent:Connect(function(player, target: types.IncomingConnector, ...) + --- check if the player is allowed to send this + if not do_not_block_traffic and not traffic_check.check(player, target.host) then + return + end + + --- check if its a proper connector + if not tincoming_connector(target) then return end + + if target.host == "server" and (target.to_vm == vm_id or target.to_vm == nil) then + target.host = player + fire(target, ...) + elseif target.host ~= "server" and vm_id == MANAGER_VM then + local to = target.host + target.host = player + remote:FireClient( + to, + target, + ... + ) + end + end) + else + remote.OnClientEvent:Connect(function(incoming: types.IncomingConnector, ...) + -- print("receive", remote.Name, "from", incoming.host) + if tincoming_connector(incoming) == false then return end + if incoming.to_vm ~= vm_id and incoming.to_vm ~= nil then return end + traffic_check._whitelist(local_host, incoming.host) + + fire(incoming, ...) + end) + end + + remote:WaitForChild("actor").Event:Connect(function(incoming: types.IncomingConnector, ...) + if incoming.to_vm ~= vm_id and incoming.to_vm ~= nil then return end + fire(incoming, ...) + end) + + return (event :: any) :: types.NetEvent +end + +return { + create_event = create_event, + local_host = local_host +} diff --git a/modules/Jabby/modules/queue.luau b/modules/Jabby/modules/queue.luau new file mode 100644 index 0000000..2cebc72 --- /dev/null +++ b/modules/Jabby/modules/queue.luau @@ -0,0 +1,115 @@ +--- Licensed under MIT from centau_ri +export type Queue = typeof(setmetatable( + {} :: { + add: (self: Queue, T...) -> (), + clear: (self: Queue) -> (), + iter: (self: Queue) -> () -> T..., + }, + {} :: { + __len: (self: Queue) -> number, + __iter: (self: Queue) -> () -> T..., + } +)) + +type Array = { T } + +local Queue = {} +do + Queue.__index = Queue + + type _Queue = Queue<...any> & { + size: number, + columns: Array>, + } + + function Queue.new(): Queue + local self: _Queue = setmetatable({ + size = 0, + columns = {}, + }, Queue) :: any + + setmetatable(self.columns, { + __index = function(columns: Array>, idx: number) + columns[idx] = {} + return columns[idx] + end, + }) + + return self :: Queue + end + + function Queue.add(self: _Queue, ...: unknown) + -- iteration will stop if first value is `nil` + assert((...) ~= nil, "first argument cannot be nil") + + local columns = self.columns + local n = self.size + 1 + self.size = n + + for i = 1, select("#", ...) do + columns[i][n] = select(i, ...) + end + end + + function Queue.clear(self: _Queue) + self.size = 0 + for _, column in next, self.columns do + table.clear(column) + end + end + + local function iter(self: _Queue) + local columns = self.columns + local n = self.size + local i = 0 + + if #columns <= 1 then + local column = columns[1] + return function() + i += 1 + local value = column[i] + if i == n then self:clear() end + return value + end + else + local tuple = table.create(#columns) + return function() + i += 1 + for ci, column in next, columns do + tuple[ci] = column[i] + end + if i == n then self:clear() end + return unpack(tuple) + end + end + end + + Queue.iter = iter + Queue.__iter = iter + + function Queue.__len(self: _Queue) + return self.size + end +end + +type ISignal = { + connect: (self: any, listener: (T...) -> ()) -> (), +} | { + Connect: (self: any, listener: (T...) -> ()) -> (), +} + +local queue_create = function(signal: ISignal?): Queue + local queue = Queue.new() + + if signal then + local connector = (signal :: any).connect or (signal :: any).Connect + assert(connector, "signal has no member `connect()`") + connector(signal, function(...) + queue:add(...) + end) + end + + return queue +end :: (() -> Queue) & ((signal: ISignal) -> Queue) + +return queue_create \ No newline at end of file diff --git a/modules/Jabby/modules/remotes.luau b/modules/Jabby/modules/remotes.luau new file mode 100644 index 0000000..b9a0ff9 --- /dev/null +++ b/modules/Jabby/modules/remotes.luau @@ -0,0 +1,359 @@ +local jecs = require(script.Parent.Parent.jecs) +local net = require(script.Parent.net) +local types = require(script.Parent.types) + +--todo: redo this file +type Query = { + include: { jecs.Entity }, + exclude: { jecs.Entity }, + with: { jecs.Entity }, +} + +return { + + --[=[ + Broadcasts to clients that a new server has been registered. + Accepts no params. + ]=] + new_server_registered = net.create_event("server_registered", false, true) + :: types.NetEvent<>, + + --[=[ + Pings all servers and make them respond with new_server_registered + ]=] + ping = net.create_event("ping", false, true) + :: types.NetEvent<>, + + --[=[ + Broadcasts to servers that a new client has been registered + Accepts no params. + ]=] + bind_to_server_core = net.create_event("client_registered") + :: types.NetEvent<>, + + --[=[ + Sends a update to a client about a new server + ]=] + update_server_data = + net.create_event("update_server_data") + :: types.NetEvent<{ + worlds: {{id: number, name: string}}, + schedulers: {{id: number, name: string}} + }>, + + --[=[ + The client will use this to send the mouse pointer to the server + ]=] + send_mouse_pointer = + net.create_event("send_mouse_pointer") + :: types.NetEvent, + + --[[ + + + ]] + send_mouse_entity = + net.create_event("send_mouse_entity", true) + :: types.NetEvent, + + --[=[ + Requests the server to validate a query + world: number + query: string + ]=] + validate_query = + net.create_event("validate_query") + :: types.NetEvent, + + --[=[ + Result of the validation + world: number query: string, terms: {}, ok: boolean, message: string? + ]=] + validate_result = + net.create_event("validate_result") + :: types.NetEvent, + + --[=[ + Requests a server to initiate replication of a query. + + world: number + id: number + query_id: number + query: string + ]=] + request_query = + net.create_event("replicate_query") + :: types.NetEvent, + + --[=[ + Disconnects query + + query_id: number + ]=] + disconnect_query = + net.create_event("disconnect_query") + :: types.NetEvent, + + --[=[ + Changes the offsets to query for + + query_id: number + from: number + to: number + ]=] + advance_query_page = + net.create_event("advance_query_page") + :: types.NetEvent, + + --- pause the query + --- query id + --- should pause + pause_query = + net.create_event("pause_query") + :: types.NetEvent, + + --- refreshes query results + --- query_id + refresh_results = + net.create_event("refresh_query") + :: types.NetEvent, + + --[=[ + Updates a single result + + query_id: number + frame: number + column: number + row: number + value: any + ]=] + update_query_result = + net.create_event("update_query_result", true) + :: types.NetEvent<(number, number, number, number, any)>, + + --[=[ + Counts the total number of entities + + query id: number + count: number + ]=] + + count_total_entities = + net.create_event("count_total_entities", true) + :: types.NetEvent, + + --[=[ + Requests a server to initiate replication of a scheduler + ]=] + request_scheduler = + net.create_event("initiate_replicate_scheduler") :: + types.NetEvent, + + --[=[ + Requests the server to stop replicating a scheduler + ]=] + disconnect_scheduler = + net.create_event("disconnect_replicate_scheduler") :: + types.NetEvent, + + --[=[ + Updates any static data about systems (like new systems) + + systemid: number + static_data: {} + ]=] + scheduler_system_static_update = + net.create_event("scheduler_system_update_static") :: + types.NetEvent, + + --[=[ + Appends a frame to a system + + systemid: number + frame_count: number + time_took: number + ]=] + scheduler_system_update = + net.create_event("append_frame_system", true) :: + types.NetEvent, + + --[=[ + Pauses a system + + scheduler: number + systemid: number + paused: boolean + ]=] + scheduler_system_pause = + net.create_event("scheduler_pause") :: + types.NetEvent, + + --[[ + Validates a component + + world: number + component: string + ]] + validate_entity_component = + net.create_event("validate_entity_component") :: + types.NetEvent, + + --[[ + entity component reslt + + world: number + component: string + ok: boolean + reason: string + ]] + validate_entity_component_result = + net.create_event("validate_entity_component_result") :: + types.NetEvent, + + --[[ + Inspect a entity's components + + world: number + entity: number, + inspectid: number + ]] + + inspect_entity = + net.create_event("inspect_entity") :: + types.NetEvent, + + --[=[ + Gets the component of an entity + + inspect: number + component: string + ]=] + get_component = + net.create_event("get_entity_component") :: + types.NetEvent, + + --[=[ + Returns the component of an entity + + inspect: number + component: string + value: string + ]=] + return_component = + net.create_event("return_entity_component") :: + types.NetEvent, + + --[[ + Delete entity + + inspectid: number + ]] + delete_entity = + net.create_event("delete_entity") :: + types.NetEvent, + + --[[ + Stops inspecting a entity + + inspectid: number + ]] + stop_inspect_entity = + net.create_event("stop_inspect_entity") :: + types.NetEvent, + + --[[ + Updates a entity + + inspectid: number + changes: {[component]: string} + ]] + update_entity = + net.create_event("update_entity") :: + types.NetEvent, + + --[[ + Update the settings when dealing with inspecting + + inspectid: nuimber, + settings: {} + ]] + update_inspect_settings = + net.create_event("inspect_entity_settings_update") :: + types.NetEvent, + + --[[ + Inspector update + + inspectid: number + key: string + value: string + ]] + inspect_entity_update = + net.create_event("inspect_entity_update") :: + types.NetEvent, + + --[[ + Creates a watch on a system + + scheduler: number, + system: number + watchid: number + ]] + create_watch = + net.create_event("create_watch") :: + types.NetEvent, + + --[[ + Removes a watch on a system + + watchid: number + ]] + remove_watch = + net.create_event("remove_watch") :: + types.NetEvent, + + --[[ + Retrieves data about a frame for a watch + + watchid: number + frame: number + ]] + request_watch_data = + net.create_event("request_watch_data") :: + types.NetEvent, + + --[[ + Updates watch data for a frame + + watchid: number + frame: number + changes: types.WatchLoggedChanges + ]] + update_watch_data = + net.create_event("update_watch_data") :: + types.NetEvent, + + start_record_watch = + net.create_event("start_record_watch") :: + types.NetEvent, + + stop_watch = + net.create_event("stop_watch") :: + types.NetEvent, + + clear_watch = + net.create_event("clear_watch") :: + types.NetEvent, + + connect_watch = + net.create_event("connect_to_watch") :: + types.NetEvent, + + disconnect_watch = + net.create_event("disconnect_watch") :: + types.NetEvent, + + update_overview = + net.create_event("update_watch_overview", true) :: + types.NetEvent +} \ No newline at end of file diff --git a/modules/Jabby/modules/reverse_connector.luau b/modules/Jabby/modules/reverse_connector.luau new file mode 100644 index 0000000..3b154b8 --- /dev/null +++ b/modules/Jabby/modules/reverse_connector.luau @@ -0,0 +1,11 @@ +--!nocheck +local types = require(script.Parent.types) + +local function reverse(connector: types.IncomingConnector): types.OutgoingConnector + return { + host = connector.host, + to_vm = connector.from_vm, + } +end + +return reverse \ No newline at end of file diff --git a/modules/Jabby/modules/signal.luau b/modules/Jabby/modules/signal.luau new file mode 100644 index 0000000..b4faf61 --- /dev/null +++ b/modules/Jabby/modules/signal.luau @@ -0,0 +1,75 @@ +--[[ + + A rudimentary signal class. Yielding may cause bugs. + +]] + +local signal = {} +signal.__index = signal + +type Connection = { disconnect: (any?) -> (), reconnect: (any?) -> () } +export type Signal = { + + class_name: "Signal", + + connect: (Signal, callback: (T...) -> ()) -> Connection, + wait: (Signal) -> T..., + once: (Signal, callback: (T...) -> ()) -> Connection, + + callbacks: { [(T...) -> ()]: true }, +} +export type SignalInternal = Signal & { + fire: (SignalInternal, T...) -> (), +} + +function signal.connect(self: Signal, callback: (T...) -> ()) + assert(type(callback) == "function") + self.callbacks[callback] = true + + return { + disconnect = function() self.callbacks[callback] = nil end, + reconnect = function() self.callbacks[callback] = true end, + } +end + +function signal.fire(self: Signal, ...: T...) + for callback in self.callbacks do + callback(...) + end +end + +function signal.once(self: Signal, callback: (T...) -> ()) + local connection + connection = self:connect(function(...) + connection:disconnect() + callback(...) + end) + + return connection +end + +function signal.wait(self: Signal) + local thread = coroutine.running() + + local connection = self:connect(function(...) coroutine.resume(thread, ...) end) + local packed = { coroutine.yield() } + connection:disconnect() + return unpack(packed) +end + +local function new_signal(): (Signal, (T...) -> ()) + local self = setmetatable({ + class_name = "Signal", + callbacks = {}, + }, signal) + + local function fire(...) + for callback in self.callbacks :: any do + callback(...) + end + end + + return self :: any, fire +end + +return new_signal \ No newline at end of file diff --git a/modules/Jabby/modules/traffic_check.luau b/modules/Jabby/modules/traffic_check.luau new file mode 100644 index 0000000..79e3a44 --- /dev/null +++ b/modules/Jabby/modules/traffic_check.luau @@ -0,0 +1,82 @@ +local Players = game:GetService("Players") +--[[ + +a utility library to handle checking traffic and determining if the sender is +permitted to send the given data. + +]] + +local signal = require(script.Parent.signal) + +local traffic_check = {} + +local whitelist_player_to = {} + +local on_fail, fire = signal() + +--- A function that needs to be overwritten by the user. +--- This function is used to find out what permissions a user may have. +traffic_check.can_use_jabby = function(player: Player) + local is_studio = game:GetService("RunService"):IsStudio() + + return is_studio --is_owner or is_studio +end + +--- Runs a callback defined by the developer to determine if a player is allowed +--- to use a given function +local function communication_is_allowed(from: "server" | Player, to: "server" | Player, dont_whitelist: boolean?) + if from == "server" then return true end + + whitelist_player_to[from] = whitelist_player_to[from] or {} + whitelist_player_to[to] = whitelist_player_to[to] or {} + + if traffic_check.can_use_jabby(from) or whitelist_player_to[from][to] then + if dont_whitelist then return true end + whitelist_player_to[to][from] = from + return true + else + return false + end +end + +--- Runs the given check and fires the on_fail signal if the player fails the +--- check. +local function check(from: "server" | Player, to: "server" | Player, dont_whitelist: boolean?) + if communication_is_allowed(from, to, dont_whitelist) then + return true + else + fire(from) + return false + end +end + +local function check_no_wl(from: "server" | Player) + if from == "server" then return true end + if traffic_check.can_use_jabby(from) then + return true + else + -- print(from, "cant use jabby") + fire(from) + return false + end +end + +local function _whitelist(from: "server" | Player, to: "server" | Player) + whitelist_player_to[from] = whitelist_player_to[from] or {} + whitelist_player_to[to] = whitelist_player_to[to] or {} + whitelist_player_to[from][to] = from +end + +traffic_check.communication_is_allowed = communication_is_allowed +traffic_check.check_no_wl = check_no_wl +traffic_check.check = check + +traffic_check._whitelist = _whitelist + +traffic_check.on_fail = on_fail + +Players.PlayerRemoving:Connect(function(player) + whitelist_player_to[player] = nil +end) + +return traffic_check \ No newline at end of file diff --git a/modules/Jabby/modules/types.luau b/modules/Jabby/modules/types.luau new file mode 100644 index 0000000..195c49e --- /dev/null +++ b/modules/Jabby/modules/types.luau @@ -0,0 +1,117 @@ +local jecs = require(script.Parent.Parent.jecs) +type host = "client" | "server" + +export type IncomingConnector = { + host: Player | "server", + from_vm: number, + to_vm: number +} + +export type OutgoingConnector = { + host: Player | "server", + to_vm: number?, -- not specifying a vm makes it received by all + from_vm: nil +} + +export type NetEvent = { + + type: "event", + + fire: (any, connector: OutgoingConnector, T...) -> (), + connect: (any, callback: (connector: IncomingConnector, T...) -> ()) -> RBXScriptConnection, +} + +export type NetCallback = { + + type: "callback", + + invoke: (any, connector: OutgoingConnector, T...) -> U..., + set_callback: (any, callback: (connector: IncomingConnector, T...) -> U...) -> (), +} + +export type SystemId = number + +export type SystemTag = "processing" | "finished" | "paused" +export type SystemSettingData = { + name: string?, + phase: string?, + layout_order: number?, + paused: boolean? +} +export type SystemData = { + name: string, + phase: string?, + layout_order: number, + paused: boolean +} + +type ChangeTypes = "remove" | "clear" | "delete" | "add" | "set" | "entity" | "component" +export type WatchLoggedChanges = { + types: {ChangeTypes}, + entities: {jecs.Entity}, + component: {jecs.Entity}, + values: {string}, + worlds: {jecs.World} +} + +export type SystemWatch = { + --- enables Lua Object Notation. + --- incurs a significant performance penalty. + enable_lon: boolean, + --- the current frame to process + frame: number, + + frames: {[number]: WatchLoggedChanges} +} + +export type SystemLabel = {} + +export type SystemFrame = { + i: number, + s: number +} + +type WatchData = {active: boolean, watch: SystemWatch, untrack: () -> ()} + +export type Scheduler = { + class_name: "Scheduler", + name: string, + + valid_system_ids: {[SystemId]: true}, + system_data: {[SystemId]: SystemData}, + system_data_updated: {[SystemId]: true}, + system_frames: {[SystemId]: SystemFrame}, + system_frames_updated: {[SystemId]: {[SystemFrame]: true}}, + system_watches: {[SystemId]: {WatchData}}, + + register_system: (Scheduler, settings: SystemSettingData?) -> SystemId, + set_system_data: (Scheduler, system: SystemId, settings: SystemSettingData) -> (), + get_system_data: (Scheduler, system: SystemId) -> SystemSettingData, + create_watch_for_system: (Scheduler, system: SystemId) -> WatchData, + remove_system: (Scheduler, system: SystemId) -> (), + + -- mark_system_frame_start: (Scheduler, system: SystemId) -> (), + -- mark_system_frame_end: (Scheduler, system: SystemId, s: number?) -> (), + -- append_extra_frame_data: (Scheduler, system: SystemId, label: SystemLabel) -> (), + --- this should call mark_system_frame_start and mark_system_frame_end for you + run: (Scheduler, system: SystemId, system: () -> (), T...) -> (), + +} + +export type World = { + class_name: "World", + name: string, + world: jecs.World, + + entities: {[Instance]: jecs.Entity}?, + get_entity_from_part: ((part: BasePart) -> (jecs.Entity?, Part?))? +} + +export type Application = { + class_name: "app", + name: string, + + mount: (props: T, destroy: () -> ()) -> Instance +} + +return nil \ No newline at end of file diff --git a/modules/Jabby/modules/videx.luau b/modules/Jabby/modules/videx.luau new file mode 100644 index 0000000..6a07c53 --- /dev/null +++ b/modules/Jabby/modules/videx.luau @@ -0,0 +1,71 @@ +-------------------------------------------------------------------------------- +-- videx/store.luau +-------------------------------------------------------------------------------- +local vide = require(script.Parent.Parent.vide) +local source = vide.source + +local NULL = newproxy() + +local Store = {} + +--[=[ +Creates a new store object that receives some initial state and then returns +a table with the same structure, but all keys of the given table will be reactive. + +When accessed inside a reactive scope, the reactive scope will update whenever +the key that is accessed is changed. + +@param initial_state `T : {[string]: any}` The initial state the store will start in. +@param mutations `() -> {[string]: (T, ...any) -> ...any}?` A list of functions that mutate the data. +@return `T & U` A resulting table that +]=] +function Store.new( + initial_state: T & {}, + mutations: (T & U) -> U +): T & U + local sources = {} + + for i, v in initial_state :: any do + local src = source(v ~= NULL and v or nil) + sources[i] = src + end + + local internal_proxy = {} + + setmetatable(internal_proxy, { + __index = function(_, index) + return sources[index]() + end, + __newindex = function(_, index, value) + sources[index](value) + end + }) + + local external_proxy = {} + + setmetatable(external_proxy :: any, { + __index = function(_, index) + local src = sources[index] + if src == nil then error(`invalid index {index}`, 2) end + return src() + end, + + __newindex = function(_, index, value) + sources[index](value) + end + }) + + for i, v in next, mutations(internal_proxy :: any) :: any do + if rawget(external_proxy, i) then + error(`duplicate field "{i}"`, 2) + end + rawset(external_proxy, i, v) + end + + return external_proxy :: T & U & {} +end + +--- A special symbol used to indicate that a value should be nil within a Store. +Store.null = NULL :: nil + +return Store \ No newline at end of file diff --git a/modules/Jabby/modules/vm_id.luau b/modules/Jabby/modules/vm_id.luau new file mode 100644 index 0000000..cf0c1fb --- /dev/null +++ b/modules/Jabby/modules/vm_id.luau @@ -0,0 +1,14 @@ +--[[ + +Provides a unique identifier for a VM. + +This currently cannot be tested unless there is some parallel system for jest. + +]] + +local SharedTableRegistry = game:GetService("SharedTableRegistry") + +local shared_table = SharedTableRegistry:GetSharedTable("_gorp_common_vm_count") +shared_table.id = shared_table.id or 0 + +return SharedTable.increment(shared_table, "id", 1) \ No newline at end of file diff --git a/modules/Jabby/server/init.luau b/modules/Jabby/server/init.luau new file mode 100644 index 0000000..b8f9470 --- /dev/null +++ b/modules/Jabby/server/init.luau @@ -0,0 +1,42 @@ +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +local loop = require(script.Parent.modules.loop) +local remotes = require(script.Parent.modules.remotes) +local traffic_check = require(script.Parent.modules.traffic_check) +local vm_id = require(script.Parent.modules.vm_id) +local function broadcast() + for _, player in Players:GetPlayers() do + if not traffic_check.can_use_jabby(player) then continue end + remotes.new_server_registered:fire({ + host = player, + }) + end +end + +task.delay(0, broadcast) + +local systems = script.systems +local loop = loop ( + `jabby-host:{ + if RunService:IsServer() then "server" else "client" + }-vm:{vm_id}`, + nil, + {i = 1}, + + systems.ping, + systems.replicate_core, + systems.replicate_scheduler, + systems.replicate_registry, + systems.replicate_system_watch, + systems.mouse_pointer, + systems.entity +) + +RunService.PostSimulation:Connect(loop) + +return { + + broadcast = broadcast + +} \ No newline at end of file diff --git a/modules/Jabby/server/public.luau b/modules/Jabby/server/public.luau new file mode 100644 index 0000000..174ba67 --- /dev/null +++ b/modules/Jabby/server/public.luau @@ -0,0 +1,16 @@ +--[[ + +anything in here is considered "public" and will be visible to jabby clients + +]] + +local self = { + + updated = false, + +} :: { + updated: boolean, + [number]: any +} + +return self \ No newline at end of file diff --git a/modules/Jabby/server/query_parser.luau b/modules/Jabby/server/query_parser.luau new file mode 100644 index 0000000..61855de --- /dev/null +++ b/modules/Jabby/server/query_parser.luau @@ -0,0 +1,402 @@ +--!strict + +local escape_chars = { + a = "\a", + b = "\b", + f = "\f", + n = "\n", + r = "\r", + t = "\t", + v = "\v", + ["\\"] = "\\", + ["\""] = "\"", + ["\'"] = "\'" +} + +export type Position = { + line: number, + pos: number, + col: number +} + +export type String = {type: "string", s: string} +export type Number = {type: "number", s: number} +export type Identifier = {type: "identifier", s: string} +export type Operator = {type: "operator", s: "#" | "!" | "*" | "$"} +export type Symbol = {type: "symbol", s: "(" | ")" | ";" | ","} +export type EOF = {type: "eof", s: "eof"} + +export type Token = + String + | Number + | Identifier + | Symbol + | Operator + | EOF + + +export type Stream = { + next: () -> Token, + peek: (n: number?) -> Token, + eof: () -> boolean, + croak: (msg: string) -> (), + pos: () -> Position +} + +local function stream(input: string) + + local pos = 0 + local line = 1 + local col = 1 + + local function peek(): string + return string.sub(input, pos+1, pos+1) + end + + local function next(): string + local char = peek() + pos += 1 + + if char == "\n" then line += 1; col = 1 + else col += 1 end + + return char + end + + local function eof(): boolean + return peek() == "" + end + + local function position() + return { + pos = pos, + line = line, + col = col + } + end + + local function croak(msg) + error(`{msg} ({line}:{col})`, 0) + end + + return { + peek = peek, + next = next, + eof = eof, + croak = croak, + pos = position + } + +end + +local function lex(source: string): Stream + + local input = stream(source) + + local function is_whitespace(char: string) + return not not string.match(char, "[\t ]") + end + + local function is_digit(char: string) + return not not (string.match(char, "%d")) + end + + local function is_start_identifier(char: string) + return not not string.match(char, "[%a_]") + end + + local function is_identifier(char: string) + return not not string.match(char, "[%a_:%.]") + end + + local function is_op_char(char: string) + return char == "#" or char == "!" or char == "*" or char == "$" + end + + local function is_punc(char: string) + return not not string.match(char, "[%(%);,]") + end + + local function read_while(predicate: (char: string) -> boolean) + local str = "" + while input.eof() == false and predicate(input.peek()) do + str ..= input.next() + end + return str + end + + local function skip_whitespace() + read_while(is_whitespace) + end + + local function read_string(): String + local escaped = false + local token = "" + local eliminator = input.next() + local from = input.pos() + + while input.eof() == false and (input.peek() ~= eliminator or escaped) do + local char = input.next() + + if char == "\\" then + escaped = true + end + + if escaped then + token ..= escape_chars[char] or input.croak(`cannot escape {char}`) + escaped = false + else + token ..= char + end + end + + local to = input.pos() + + -- print("t", token, eliminator, input.peek()) + + if input.peek() ~= eliminator then input.croak("unterminated string") end + input.next() + return {type = "string", s = token, from = from, to = to} + end + + local function read_number(): Number + local decimal_pointer = false + local from = input.pos() + local token = read_while(function(char) + if decimal_pointer and char == "." then return false end + if char == "." then decimal_pointer = true end + return is_digit(char) + end) + local to = input.pos() + + local n = tonumber(token) + + if not n then + input.croak(`could not read {token} as number`) + end + + return {type = "number", s = assert(n), from = from, to = to} + end + + local function read_identifier(): Identifier + local from = input.pos() + local token = read_while(is_identifier) + local to = input.pos() + + -- if table.find(keywords, token) then + -- return {type = "keyword", s = token :: any, from = from, to = to} + -- else + return {type = "identifier", s = token, from = from, to = to} + -- end + end + + local function read_next(): Token + skip_whitespace() + if input.eof() then return {type = "eof", s = "eof"} end + + local char = input.peek() + + if char == "\"" or char == "'" then return read_string() end + if is_digit(char) then return read_number() end + if is_start_identifier(char) then return read_identifier() end + if is_op_char(char) then return {type = "operator", s = input.next() :: any} end + if is_punc(char) then return {type = "symbol", s = input.next() :: any} end + + input.croak(`cannot lex {char}`) + error("fail") + end + + local current: {Token} = {} + + local function next() + local token = table.remove(current, 1) + return if token == nil then read_next() else token + end + + local function peek(n: number?) + local n = n or 1 + while #current < n do + table.insert(current, read_next()) + end + return current[n] + end + + local function eof() + return peek().type == "eof" + end + + return { + + peek = peek, + next = next, + eof = eof, + croak = input.croak, + pos = input.pos + + } +end + +type Wildcard = { + type: "Wildcard", + name: "*" +} + +export type Value = { + type: "Name", + name: string +} | { + type: "Entity", + entity: number +} + +export type PureComponent = { + type: "Component", + query: boolean, + exclude: boolean, + value: Value, +} + +export type Relationship = { + type: "Relationship", + query: boolean, + exclude: boolean, + left: PureComponent | Wildcard, + right: PureComponent | Wildcard +} + +type Component = Relationship | PureComponent | Wildcard + +local function parse(input: string): {PureComponent | Relationship} + local lexer = lex(input) + + local result: {PureComponent | Relationship} = {} + + local should_query = true + local should_exclude = false + local should_relationship = false + local interpret_pointer = false + local components: {Component} = {} + + while true do + local symbol = lexer.peek() + -- print2(symbol) + if symbol.type == "eof" then + break + elseif interpret_pointer or symbol.type == "number" then + if not interpret_pointer then + lexer.croak("expected $") + elseif symbol.type ~= "number" then + lexer.croak("expected number") + error("") + end + + table.insert(components, { + type = "Component", + query = should_query, + exclude = should_exclude, + value = {type = "Entity", entity = tonumber(lexer.next().s) :: number} + }) + + should_query = if should_relationship then should_query else true + should_exclude = if should_relationship then should_exclude else false + interpret_pointer = false + if lexer.peek().type ~= "symbol" and lexer.peek().type ~= "eof" then lexer.croak("expected symbol or eof after identifier") end + elseif symbol.type == "operator" then + if symbol.s == "#" then + if should_relationship then lexer.croak("cannot tag inside relationship") end + should_query = false + lexer.next() + elseif symbol.s == "!" then + if should_relationship then lexer.croak("cannot exclude in relationship") end + should_exclude = true + should_query = false + lexer.next() + elseif symbol.s == "$" then + interpret_pointer = true + lexer.next() + elseif symbol.s == "*" then + if not should_relationship then lexer.croak("cannot use wildcards outside relationship") end + table.insert(components, { + type = "Wildcard", + name = "*" + }) + lexer.next() + end + elseif symbol.type == "symbol" then + if symbol.s == "(" then + if should_relationship == true then lexer.croak("relationship within relationship") end + should_relationship = true + lexer.next() + elseif symbol.s == ")" then + if should_relationship == false then lexer.croak("missing (") end + if #components == 2 then + local right = table.remove(components) :: Component + local left = table.remove(components) :: Component + + if left.type == "Wildcard" and right.type == "Wildcard" then + lexer.croak("both components are wildcards") + end + + components = {{ + type = "Relationship", + query = should_query, + exclude = should_exclude, + left = left :: any, + right = right :: any + }} + + should_query = true + should_exclude = false + should_relationship = false + lexer.next() + else + lexer.croak(`expected 2 components, got {#components}`) + end + elseif symbol.s == "," or symbol.s == ";" then + if should_relationship then + lexer.next() + continue + end + + local ctype = table.remove(components) + if ctype == nil then + lexer.croak("no component provided") + error("") + end + + table.insert(result, ctype :: any) + + should_query = true + should_exclude = false + lexer.next() + end + elseif symbol.type == "identifier" then + table.insert(components, { + type = "Component", + query = should_query, + exclude = should_exclude, + value = {type = "Name", name = lexer.next().s :: string} + }) + + should_query = if should_relationship then should_query else true + should_exclude = if should_relationship then should_exclude else false + if lexer.peek().type ~= "symbol" and lexer.peek().type ~= "eof" then lexer.croak("expected symbol or eof after identifier") end + elseif symbol.type == "string" then + table.insert(components, { + type = "Component", + query = should_query, + exclude = should_exclude, + value = {type = "Name", name = lexer.next().s :: string} + }) + should_query = if should_relationship then should_query else true + should_exclude = if should_relationship then should_exclude else false + if lexer.peek().type ~= "symbol" and lexer.peek().type ~= "eof" then lexer.croak("expected symbol or eof after string") end + end + end + + table.insert(result, table.remove(components) :: any) + + return result +end + +return parse \ No newline at end of file diff --git a/modules/Jabby/server/scheduler.luau b/modules/Jabby/server/scheduler.luau new file mode 100644 index 0000000..f7a181f --- /dev/null +++ b/modules/Jabby/server/scheduler.luau @@ -0,0 +1,197 @@ +local types = require(script.Parent.Parent.modules.types) +local watch = require(script.Parent.watch) + +type SystemId = types.SystemId +type SystemSettingData = types.SystemSettingData +type SystemTag = types.SystemTag + +type SystemData = types.SystemData +type ProcessingFrame = { + started_at: number +} +type SystemFrame = types.SystemFrame + +local MAX_BUFFER_SIZE = 50 + +local n = 0 +local schedulers = {} + +local function unit() end + +local function create_scheduler() + + local count = 1 + local frames = 0 + + local scheduler = { + class_name = "Scheduler", + name = "Scheduler", + + --- contains a map of valid system ids + valid_system_ids = {} :: {[SystemId]: true}, + --- contains a list of static system data that is updated infrequently + system_data = {} :: {[SystemId]: SystemData}, + --- list of system data that has updated + system_data_updated = {} :: {[SystemId]: true}, + --- contains a buffer of the last couple frames of system data that is + --- refreshed constantly + system_frames = {} :: {[SystemId]: {SystemFrame}}, + --- stores the frames that have been updated + system_frames_updated = {} :: {[SystemId]: {[SystemFrame]: true}}, + --- contains the current frame that a system is processing + processing_frame = {} :: {[SystemId]: ProcessingFrame}, + --- contains a list of watches for each system + system_watches = {} :: {[SystemId]: {{active: boolean, watch: types.SystemWatch}}} + + } + + local function ENABLE_WATCHES(id: SystemId) + local watches = scheduler.system_watches[id] + local cleanup = {} + + for i, system_watch in watches do + if system_watch.active == false then continue end + watch.step_watch(system_watch.watch) + cleanup[i] = watch.track_watch(system_watch.watch) + end + + return function() + for _, stop in cleanup do + stop() + end + end + end + + local function ASSERT_SYSTEM_VALID(id: SystemId) + assert(scheduler.valid_system_ids[id], `attempt to use unknown system with id #{id}`) + end + + function scheduler:register_system(settings: types.SystemSettingData?) + local id = count; count += 1 + scheduler.valid_system_ids[id] = true + scheduler.system_data[id] = { + name = "UNNAMED", + phase = nil, + layout_order = 0, + paused = false + } + scheduler.system_frames[id] = {} + scheduler.system_frames_updated[id] = {} + + if settings then + scheduler:set_system_data(id, settings) + end + + return id + end + + function scheduler:set_system_data(id: SystemId, settings: types.SystemSettingData) + ASSERT_SYSTEM_VALID(id) + + for key, value in settings do + scheduler.system_data[id][key] = value + end + scheduler.system_data_updated[id] = true + end + + function scheduler:get_system_data(id: SystemId) + ASSERT_SYSTEM_VALID(id) + return scheduler.system_data[id] + end + + function scheduler:remove_system(id: SystemId) + scheduler.valid_system_ids[id] = nil + scheduler.system_data[id] = nil + scheduler.system_frames[id] = nil + scheduler.system_frames_updated[id] = nil + scheduler.system_data_updated[id] = true + scheduler.system_watches[id] = nil + end + + function scheduler:_mark_system_frame_start(id: SystemId) + ASSERT_SYSTEM_VALID(id) + + scheduler.processing_frame[id] = { + started_at = os.clock() + } + end + + function scheduler:_mark_system_frame_end(id: SystemId, s: number?) + ASSERT_SYSTEM_VALID(id) + local now = os.clock() + local pending_frame_data = scheduler.processing_frame[id] + assert(pending_frame_data ~= nil, "no processing frame") + local frame = { + i = frames, + s = now - pending_frame_data.started_at + } + + frames += 1 + + scheduler.processing_frame[id] = nil + scheduler.system_frames_updated[id][frame] = true + local last_frame = scheduler.system_frames[id][MAX_BUFFER_SIZE] + if last_frame then + scheduler.system_frames_updated[id][last_frame] = nil + end + + table.insert(scheduler.system_frames[id], 1, frame) + table.remove(scheduler.system_frames[id], MAX_BUFFER_SIZE + 1) + end + + function scheduler:append_extra_frame_data(id: SystemId, label: {}) + --todo + error("todo") + end + + function scheduler:run(id: SystemId, system: (T...) -> (), ...: T...) + ASSERT_SYSTEM_VALID(id) + local system_data = scheduler.system_data[id] + + if system_data.paused then return end + + local watches = scheduler.system_watches[id] + local cleanup_watches = unit + + if watches then + cleanup_watches = ENABLE_WATCHES(id) + end + + scheduler:_mark_system_frame_start(id) + system(...) + scheduler:_mark_system_frame_end(id) + + cleanup_watches() + end + + function scheduler:create_watch_for_system(id: SystemId) + ASSERT_SYSTEM_VALID(id) + + local new_watch = watch.create_watch() + local watch_data + scheduler.system_watches[id] = scheduler.system_watches[id] or {} :: never + + local function untrack() + local idx = table.find(scheduler.system_watches[id], watch_data) + table.remove(scheduler.system_watches[id], idx) + end + + watch_data = {active = false, watch = new_watch, untrack = untrack} + table.insert(scheduler.system_watches[id], watch_data) + + return watch_data + end + + schedulers[n + 1] = scheduler + n = n + 1 + + return scheduler + +end + +return { + + create = create_scheduler, + schedulers = schedulers + +} \ No newline at end of file diff --git a/modules/Jabby/server/systems/entity.luau b/modules/Jabby/server/systems/entity.luau new file mode 100644 index 0000000..c4c7de3 --- /dev/null +++ b/modules/Jabby/server/systems/entity.luau @@ -0,0 +1,294 @@ +local jecs = require(script.Parent.Parent.Parent.jecs) +local lon = require(script.Parent.Parent.Parent.modules.lon) +local queue = require(script.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.modules.remotes) +local reverse_connector = require(script.Parent.Parent.Parent.modules.reverse_connector) +local traffic_check = require(script.Parent.Parent.Parent.modules.traffic_check) +local types = require(script.Parent.Parent.Parent.modules.types) +local public = require(script.Parent.Parent.public) +local query_parser = require(script.Parent.Parent.query_parser) + +local entity_index_try_get = jecs.entity_index_try_get +local IS_PAIR = jecs.IS_PAIR +local pair = jecs.pair +local pair_first = jecs.pair_first +local pair_second = jecs.pair_second +local empty_table = {} + +local function get_all_components(world, entity): {} + local record = entity_index_try_get(world.entity_index, entity) + + if not record then return empty_table end + local archetype = record.archetype + if not archetype then return empty_table end + + local components = {} + for _, ty in archetype.types do + table.insert(components, ty) + end + return components +end + +local function convert_component(world, debug, entity): string + if IS_PAIR(entity) then + local left = convert_component(world, debug, pair_first(world, entity)) + local right = convert_component(world, debug, pair_second(world, entity)) + return `({left}, {right})` + else + return world:get(entity, debug) or `${tostring(entity)}` + end +end + +--- Indicates a value is a tag +local TAG = newproxy() +--- Indicates a value is set to nil; this is not allowed in 0.3.0 +local BAD_VALUE = newproxy() + +local function get_component(ctype_name: string, map_components: {[string]: number}) + + local function get_entity(ctype: query_parser.PureComponent) + local value = ctype.value + + if value.type == "Entity" then + return value.entity + elseif value.type == "Name" then + return map_components[value.name] + end + error("bad") + end + + local entity_to_set + local parse = query_parser(ctype_name)[1] + if parse.type == "Component" then + entity_to_set = get_entity(parse) + elseif parse.type == "Relationship" then + local left, right = jecs.Wildcard, jecs.Wildcard + + if parse.left.type == "Component" then + left = get_entity(parse.left) + end + + if parse.right.type == "Component" then + right = get_entity(parse.right) + end + + entity_to_set = pair(left, right) + end + + return entity_to_set +end + +return function() + + local inspect_entity = queue(remotes.inspect_entity) + local update_inspect_settings = queue(remotes.update_inspect_settings) + local stop_inspect_entity = queue(remotes.stop_inspect_entity) + local update_entity = queue(remotes.update_entity) + local delete_entity = queue(remotes.delete_entity) + + local get_entity_component = queue(remotes.get_component) + local validate_entity = queue(remotes.validate_entity_component) + + local inspectors = {} + + return function() + + for incoming, world_id, ctype_name in validate_entity:iter() do + local world: types.World = public[world_id] + local outgoing = reverse_connector(incoming) + + if not traffic_check.check_no_wl(incoming.host) then continue end + if not world or world.class_name ~= "World" then continue end + + local map_components = {} + for id, name in world.world:query(jecs.Name):iter() do + map_components[name] = id + end + + local ok, reason = pcall(get_component, ctype_name, map_components) + + remotes.validate_entity_component_result:fire( + outgoing, world_id, ctype_name, ok, not ok and reason or nil + ) + end + + for incoming, world_id, entity, inspect_id in inspect_entity:iter() do + local world: types.World = public[world_id] + local outgoing = reverse_connector(incoming) + + if not traffic_check.check_no_wl(incoming.host) then continue end + if not world or world.class_name ~= "World" then continue end + + inspectors[inspect_id] = { + outgoing = outgoing, + world = world, + entity = entity, + paused = false, + + new_values = {}, + old_values = {} + } + end + + for incoming, inspect_id in stop_inspect_entity:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + inspectors[inspect_id] = nil + end + + for incoming, inspect_id in delete_entity:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + local inspect_data = inspectors[inspect_id] + local world_data = inspect_data.world + local world = world_data.world + local entity = inspect_data.entity + + world:delete(entity) + end + + for incoming, inspect_id, settings in update_inspect_settings:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + local inspect_data = inspectors[inspect_id] + if not inspect_data then continue end + inspect_data.paused = settings.paused + end + + for incoming, inspect_id, component in get_entity_component:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + local inspect_data = inspectors[inspect_id] + local world_data = inspect_data.world + local world = world_data.world + local entity = inspect_data.entity + local to = reverse_connector(incoming) + + local map_components = {} + for id, name in world:query(jecs.Name):iter() do + map_components[name] = id + end + + local ok, component = pcall(get_component, component, map_components) + + if component and ok then + remotes.return_component:fire(to, inspect_id, component, lon.output(world:get(entity, component), true, false)) + else + remotes.return_component:fire(to, inspect_id, component, "nil") + end + end + + for incoming, inspect_id, changes in update_entity:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + local inspect_data = inspectors[inspect_id] + local world_data = inspect_data.world + local world = world_data.world + local entity = inspect_data.entity + + local map_components = {} + for id, name in world:query(jecs.Name):iter() do + map_components[name] = id + end + + for ctype_name, value in changes do + + -- get the component we need to set + local ok, entity_to_set = pcall(get_component, ctype_name, map_components) + local old = world:get(entity, entity_to_set) + + if not ok then + warn("attempted to set", ctype_name, "to", value) + warn(entity_to_set) + continue + end + + local ok, result = pcall(lon.parse, value) + + if not ok then + warn("attempted to set", ctype_name, "to", value) + warn(result) + continue + end + + local ok, data = pcall( + lon.compile, + result, + { tag = TAG, old = old } + ) + + if not ok then + warn("attempted to set", ctype_name, "to", value) + warn(data) + continue + end + + if data == nil then + world:remove(entity, entity_to_set) + elseif data == TAG then + -- trying to use world:set with a tag will result in an error, + -- even if the data is nil, so instead we use world:add + world:add(entity, entity_to_set) + else + world:set(entity, entity_to_set, data) + end + end + end + + for inspect_id, inspector_data in inspectors do + local world = inspector_data.world.world + local entity = inspector_data.entity + + if inspector_data.paused then continue end + if world:contains(entity) == false then continue end + + local new_values = inspector_data.new_values + local old_values = inspector_data.old_values + + local components = get_all_components(world, entity) + + local function is_tag(id: jecs.Entity) + return jecs.is_tag(world, id) + end + + for _, component in components do + local name = convert_component(world, jecs.Name, component) + local is_tag = is_tag(component) + + if is_tag then + new_values[name] = TAG + else + local value = world:get(entity, component) + new_values[name] = if value == nil then BAD_VALUE else value + end + end + + for name, new_value in new_values do + local old_value = old_values[name] + + if old_value == new_value and typeof(new_value) ~= "table" then continue end + remotes.inspect_entity_update:fire( + inspector_data.outgoing, + inspect_id, + name, + if new_value == TAG then "tag" + --todo: figure out a better way to say that you are not allowed to store nil in a component + elseif new_value == BAD_VALUE then "nil (not allowed)" + else lon.output(new_value, true, true) + ) + end + + for name, value in old_values do + local new_value = new_values[name] + + if new_value ~= nil then continue end + remotes.inspect_entity_update:fire( + inspector_data.outgoing, + inspect_id, + name, + nil + ) + end + + table.clear(old_values) + + inspector_data.new_values = old_values + inspector_data.old_values = new_values + end + end +end \ No newline at end of file diff --git a/modules/Jabby/server/systems/mouse_pointer.luau b/modules/Jabby/server/systems/mouse_pointer.luau new file mode 100644 index 0000000..4ee6b64 --- /dev/null +++ b/modules/Jabby/server/systems/mouse_pointer.luau @@ -0,0 +1,209 @@ +local jecs = require(script.Parent.Parent.Parent.jecs) +local queue = require(script.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.modules.remotes) +local reverse_connector = require(script.Parent.Parent.Parent.modules.reverse_connector) +local traffic_check = require(script.Parent.Parent.Parent.modules.traffic_check) +local types = require(script.Parent.Parent.Parent.modules.types) +local public = require(script.Parent.Parent.public) + +local entity_index_try_get = jecs.entity_index_try_get +local IS_PAIR = jecs.IS_PAIR +local pair_first = jecs.pair_first +local pair_second = jecs.pair_second +local empty_table = {} + + +local function convert_component(world, debug, entity): string + if IS_PAIR(entity) then + local left = convert_component(world, debug, pair_first(world, entity)) + local right = convert_component(world, debug, pair_second(world, entity)) + return `({left}, {right})` + else + return world:get(entity, debug) or `${tostring(entity)}` + end +end + +local function get_type(t: { [any]: any }): string + local key, value = next(t) + if key == nil then return "" end + return `[{typeof(key)}]: {typeof(value)}` +end + +local function get_string_keys(t: { [any]: any }): ({ string }, boolean) + local keys = {} + local i = 0 + for key in t do + if i > 3 then return keys, true end + if typeof(key) ~= "string" then continue end + table.insert(keys, key) + i += 1 + end + return keys, false +end + +local function is_tag(world: jecs.World, id: jecs.Entity) + return jecs.is_tag(world, id) +end + +local function get_all_components(world, entity): {} + local record = entity_index_try_get(world.entity_index, entity) + + if not record then return empty_table end + local archetype = record.archetype + if not archetype then return empty_table end + + local components = {} + for _, ty in archetype.types do + table.insert(components, ty) + end + + table.sort(components, function(a, b) + return if is_tag(world, a) and is_tag(world, b) then a < b + elseif is_tag(world, a) then true + elseif is_tag(world, b) then false + else a < b + end) + + return components +end + +local function obtain_string(entity: jecs.Entity, world: jecs.World) + local MAX_SIZE = 840 + local has_more = false + local entity_name = world:get(entity, jecs.Name) + local strings = {`{if entity_name then `{entity_name} #` else "#"}{entity}\n`} + local n = #strings[1] + + for _, id in get_all_components(world, entity) do + if id == jecs.Name then continue end + local name = convert_component(world, jecs.Name, id) + local value = if is_tag(world, id) then nil else world:get(entity, id) + local to_append + + if typeof(value) == "table" then + local string_keys = get_string_keys(value) + + if #string_keys > 0 then + local temp_b = {`{name}:`} + local temp_n = #temp_b[1] + + for key, value in pairs(value) do + if #temp_b > 0 then + table.insert(temp_b, "\n") + end + + local str_of_v = if type(value) == "string" then `{value}` + elseif typeof(value) == "table" then get_type(value) + else tostring(value) + if #str_of_v > 32 then + str_of_v = `{string.sub(str_of_v, 1, 30)}..` + end + + local str = `{key}: {str_of_v}` + + if temp_n + #str + 2 > 32 then + table.insert(temp_b, "...") + break + else + table.insert(temp_b, str) + end + end + + to_append = `{table.concat(temp_b)}` + else + to_append = `{name}: {get_type(value)}` + end + elseif is_tag(world, id) then + to_append = `{name}` + else + local value = tostring(value) + if #value > 32 then + to_append = `{name}: {string.sub(value, 1, 30)}..` + else + to_append = `{name}: {value}` + end + end + + if MAX_SIZE > n + #to_append then + n += #to_append + table.insert(strings, to_append) + else + has_more = true + end + end + + local str = table.concat(strings, "\n") + if has_more then str = str .. "..." end + + return str +end + +return function() + + local send_mouse_pointer = queue(remotes.send_mouse_pointer) + + return function() + + for incoming, world_id, pos, dir in send_mouse_pointer:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + local world_data: types.World = public[world_id] + local world = world_data.world + local outgoing = reverse_connector(incoming) + + if world_data.entities == nil and world_data.get_entity_from_part == nil then continue end + if not world_data or world_data.class_name ~= "World" then continue end + + local result = workspace:Raycast(pos, dir * 1000) + + if not result then + remotes.send_mouse_entity:fire( + outgoing, + world_id + ) + continue + end + + local part = result.Instance + local entity + + -- no way to obtain the entity + if world_data.get_entity_from_part == nil and world_data.entities == nil then + remotes.send_mouse_entity:fire( + outgoing, + world_id + ) + continue + end + + if world_data.get_entity_from_part == nil then + entity = world_data.entities[part] + + while entity == nil and part.Parent ~= game do + part, entity = part.Parent, world_data.entities[part] + end + else + entity, part = world_data.get_entity_from_part(part) + end + + if not entity then + remotes.send_mouse_entity:fire( + outgoing, + world_id + ) + continue + end + + local str = obtain_string(entity, world, jecs.Name) + + remotes.send_mouse_entity:fire( + outgoing, + world_id, + part, + entity, + str + ) + + end + + end +end diff --git a/modules/Jabby/server/systems/ping.luau b/modules/Jabby/server/systems/ping.luau new file mode 100644 index 0000000..fe80ffc --- /dev/null +++ b/modules/Jabby/server/systems/ping.luau @@ -0,0 +1,28 @@ +local Players = game:GetService("Players") + +local net = require(script.Parent.Parent.Parent.modules.net) +local queue = require(script.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.modules.remotes) +local reverse_connector = require(script.Parent.Parent.Parent.modules.reverse_connector) +local traffic_check = require(script.Parent.Parent.Parent.modules.traffic_check) + + +return function() + + local ping = queue(remotes.ping) + + for _, player in Players:GetPlayers() do + if traffic_check.communication_is_allowed(net.local_host, player, true) then + remotes.new_server_registered:fire({ + host = player, + }) + end + end + + return function() + for connector in ping:iter() do + local outgoing = reverse_connector(connector) + remotes.new_server_registered:fire(outgoing) + end + end +end \ No newline at end of file diff --git a/modules/Jabby/server/systems/replicate_core.luau b/modules/Jabby/server/systems/replicate_core.luau new file mode 100644 index 0000000..dd47aa9 --- /dev/null +++ b/modules/Jabby/server/systems/replicate_core.luau @@ -0,0 +1,80 @@ +local queue = require(script.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.modules.remotes) +local reverse_connector = require(script.Parent.Parent.Parent.modules.reverse_connector) +local traffic_check = require(script.Parent.Parent.Parent.modules.traffic_check) +local public = require(script.Parent.Parent.public) + +return function() + + local connected = {} + + local bind_to_core = queue(remotes.bind_to_server_core) + + return function() + + for connector in bind_to_core:iter() do + local outgoing = reverse_connector(connector) + + if not traffic_check.check_no_wl(connector.host) then continue end + -- print("help") + + table.insert(connected, outgoing) + local schedulers = {} + local worlds = {} + + for idx, data in ipairs(public) do + + if data.class_name == "Scheduler" then + table.insert(schedulers, { + name = data.name :: string, + id = idx + }) + elseif data.class_name == "World" then + table.insert(worlds, { + name = data.name :: string, + id = idx + }) + end + + end + + remotes.update_server_data:fire(outgoing, { + schedulers = schedulers, + worlds = worlds + }) + end + + if public.updated == false then return end + public.updated = false + + local schedulers = {} + local worlds = {} + + for idx, data in ipairs(public) do + + if data.class_name == "Scheduler" then + table.insert(schedulers, { + name = data.name :: string, + id = idx + }) + elseif data.class_name == "World" then + table.insert(worlds, { + name = data.name :: string, + id = idx + }) + end + + end + + local fired_to = {} + for _, connector in connected do + if fired_to[connector] then return end + fired_to[connector] = true + remotes.update_server_data:fire(connector, { + schedulers = schedulers, + worlds = worlds + }) + end + + end +end \ No newline at end of file diff --git a/modules/Jabby/server/systems/replicate_registry.luau b/modules/Jabby/server/systems/replicate_registry.luau new file mode 100644 index 0000000..4572385 --- /dev/null +++ b/modules/Jabby/server/systems/replicate_registry.luau @@ -0,0 +1,503 @@ +local jecs = require(script.Parent.Parent.Parent.jecs) +local queue = require(script.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.modules.remotes) +local reverse_connector = require(script.Parent.Parent.Parent.modules.reverse_connector) +local traffic_check = require(script.Parent.Parent.Parent.modules.traffic_check) +local types = require(script.Parent.Parent.Parent.modules.types) +local public = require(script.Parent.Parent.public) +local query_parser = require(script.Parent.Parent.query_parser) + +type Connection = { + + outgoing: types.OutgoingConnector, + query_id: number, + frame: number, + + paused: boolean, + refresh: boolean, + + world: types.World, + + include: {jecs.Entity}, + exclude: {jecs.Entity}, + with: {jecs.Entity}, + + new_columns: {{any}}, + old_columns: {{any}}, + + from: number, + upto: number + +} + +local NIL = newproxy() -- NULL is displayed if the value exists, buth as no value + +local function clear_columns(columns: {{any}}) + for _, column in columns do + local name = column[1] + table.clear(column) + column[1] = name + assert(column[1] == name) + end + return columns +end + +local function reverse_columns(columns: {{any}}, size: number) + for _, column in columns do + for i = 0, size // 2 - 1 do + column[i + 2], column[(size + 1) - i] = column[(size + 1) - i], column[i + 2] + end + end + return columns +end + +return function() + + local processing_queries: {[number]: Connection} = {} + + local validate_query = queue(remotes.validate_query) + local request_query = queue(remotes.request_query) + local disconnect_query = queue(remotes.disconnect_query) + local advance_query_page = queue(remotes.advance_query_page) + local pause_query = queue(remotes.pause_query) + local refresh_query = queue(remotes.refresh_results) + + local function check_if_query_valid(world: types.World, query: string): (boolean, string) + local map_components = {} + local ok, result = pcall(query_parser, query) + local msg = nil + + if not ok then + return ok, result :: any + end + + for id, name in world.world:query(jecs.Name):iter() do + map_components[name] = id + end + + local total_to_query = 0 + + for _, ctype in result do + if not ok then break end + + if ctype.query and not ctype.exclude then + total_to_query += 1 + end + + if ctype.type == "Component" then + if ctype.value.type == "Entity" then + if world.world:contains(ctype.value.entity) then continue end + return false, "entity does not exist" + elseif ctype.value.type == "Name" then + if map_components[ctype.value.name] then continue end + return false, `unknown component called {ctype.value.name}` + else + return false, "what" + end + elseif ctype.type == "Relationship" then + local both_wildcard = ctype.left.type == "Wildcard" and ctype.right.type == "Wildcard" + if both_wildcard then + return false, `(*, *) is not a valid relationship` + end + + local left = ctype.left + local right = ctype.right + + if left.type == "Component" then + if left.value.type == "Entity" then + if world.world:contains(left.value.entity) then continue end + return false, "entity does not exist" + elseif left.value.type == "Name" then + if map_components[left.value.name] then continue end + return false, `unknown component called {left.value.name}` + else + return false, "what" + end + end + + if right.type == "Component" then + if right.value.type == "Entity" then + if world.world:contains(right.value.entity) then continue end + return false, "entity does not exist" + elseif right.value.type == "Name" then + if map_components[right.value.name] then continue end + return false, `unknown component called {right.value.name}` + else + return false, "what" + end + end + end + end + + if total_to_query > 26 then + warn("attempting to observe too many values") + return false, "attempting to observe too many entities" + end + + return ok, msg + end + + --fixme: contains is missing from types + local function check_if_still_valid(world: any, entities: {any}) + for _, id in entities do + if jecs.IS_PAIR(id) then + if not (world:contains(jecs.pair_first(world, id) and jecs.pair_second(world, id))) then + return false + end + elseif not world:contains(id) then + return false + end + end + return true + end + + local function get_terms(query: string, world: jecs.World) + local result = query_parser(query) + local include = {} + local exclude = {} + local with = {} + + local map_components = {} + local map_entity: {[any]: any} = {} + for id, name in world:query(jecs.Name):iter() do + map_components[name] = id + end + + local function get_entity(ctype: query_parser.PureComponent) + local value = ctype.value + + if value.type == "Entity" then + return value.entity + elseif value.type == "Name" then + return map_components[value.name] + end + error("bad") + end + + for _, ctype in result do + if ctype.type == "Component" then + map_entity[ctype] = get_entity(ctype) + elseif ctype.type == "Relationship" then + local left, right = jecs.Wildcard, jecs.Wildcard + + if ctype.left.type == "Component" then + left = get_entity(ctype.left) + end + + if ctype.right.type == "Component" then + right = get_entity(ctype.right) + end + + local pair = jecs.pair(left, right) + map_entity[ctype] = pair + end + end + + for _, ctype in result do + local entity = map_entity[ctype] + if ctype.exclude then + table.insert(exclude, entity) + elseif ctype.query then + -- local name = if ctype.type == "Component" then ctype.name else `({ctype.left.name}, {ctype.right.name})` + table.insert(include, entity) + else + table.insert(with, entity) + end + end + + return include, exclude, with + end + + return function() + + for incoming, world_id, query in validate_query:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + + local world: types.World = public[world_id] + local outgoing = reverse_connector(incoming) + + if not world or world.class_name ~= "World" then + remotes.validate_result:fire(outgoing, world_id, query, nil, false, "world does not exist") + continue + end + + local ok, message = check_if_query_valid(world, query) + local include, exclude, with + + if ok then include, exclude, with = get_terms(query, world.world) end + + remotes.validate_result:fire(outgoing, world_id, query, ok and { + include = include, + exclude = exclude, + with = with + }, ok, message) + end + + for incoming, query_id in disconnect_query:iter() do + processing_queries[query_id] = nil + end + + for incoming, world_id, query_id, query in request_query:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + + local world: types.World = public[world_id] + local outgoing = reverse_connector(incoming) + + if not world or world.class_name ~= "World" then continue end + + local ok = check_if_query_valid(world, query) + + if not ok then continue end + + local include, exclude, with = get_terms(query, world.world) + local new_columns = {} + local old_columns = {} + + table.insert(new_columns, {}) + table.insert(old_columns, {}) + + for _, ctype in include do + table.insert(new_columns, {}) + table.insert(old_columns, {}) + end + + if processing_queries[query_id] then + local connection = processing_queries[query_id] + + connection.outgoing = outgoing + connection.query_id = query_id + connection.world = world + connection.refresh = true + + connection.include = include + connection.exclude = exclude + connection.with = with + + connection.new_columns = new_columns + connection.old_columns = old_columns + connection.from = 1 + connection.upto = 25 + else + local connection: Connection = { + + outgoing = outgoing, + query_id = query_id, + frame = 0, + + world = world, + + paused = false, + refresh = false, + + include = include, + exclude = exclude, + with = with, + + new_columns = new_columns, + old_columns = old_columns, + + from = 1, + upto = 25 + } + + processing_queries[query_id] = connection + end + end + + for incoming, query_id in refresh_query:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + local query = processing_queries[query_id] + if not query then continue end + query.refresh = true + end + + for incoming, query_id, state in pause_query:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + local query = processing_queries[query_id] + if not query then continue end + query.paused = state + end + + for incoming, query_id, from, to in advance_query_page:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + local query = processing_queries[query_id] + if not query then continue end + + query.refresh = true + query.from = from + query.upto = to + end + + for query_id, query_data in processing_queries do + if query_data.paused and query_data.refresh ~= true then continue end + debug.profilebegin("process query") + query_data.refresh = false + local world_data = query_data.world + local world = world_data.world + local debug_trait = jecs.Name + + if + not (check_if_still_valid(world, query_data.include) + and check_if_still_valid(world, query_data.exclude) + and check_if_still_valid(world, query_data.with)) + then + -- query is no longer valid! + --todo: query is invalid, notify the client about this + debug.profileend() + continue + end + + local query = world:query(unpack(query_data.include)) + + if #query_data.exclude > 0 then + query = query:without(unpack(query_data.exclude)) + end + + if #query_data.with > 0 then + query = query:with(unpack(query_data.with)) + end + + local from = query_data.from + local upto = query_data.upto + + local new_columns = query_data.new_columns + local old_columns = query_data.old_columns + -- set the names of each column + --todo: fix type + local function get_name(entity: any) + if jecs.IS_PAIR(entity) then + local left = jecs.pair_first(world, entity) + local right = jecs.pair_second(world, entity) + return `({get_name(left)}, {get_name(right)})` + elseif entity == jecs.Wildcard :: any then + return "*" + elseif world:has(entity, debug_trait) then + return world:get(entity, debug_trait) + else + return `${entity}` + end + end + + -- set column names + for index, column in new_columns do + local e = query_data.include[index - 1] + + if e then + column[1] = get_name(e) + else + column[1] = "id" + end + end + + -- process the data into columns + -- we inline the query here, as jecs queries are in reverse to prevent iterator invalidation + -- this is usually fine, but it's annoying, as now entities are added to the first page. + --todo: pause button + local total_entities = 0 + local archetypes = query:archetypes() + + for _, archetype: jecs.Archetype in archetypes do + total_entities += #archetype.entities + end + + local entities = table.create(total_entities) + local at = total_entities + local row_entity = 1 + + for _, archetype: jecs.Archetype in archetypes do + for row = #archetype.entities, 1, -1 do + local entity = archetype.entities[row] + table.insert(entities, entity) + end + end + + table.sort(entities) + + for i = from, upto do + row_entity += 1 + local entity = entities[i] + if not entity then continue end + new_columns[1][row_entity] = entity + for idx, ctype in query_data.include do + local value = world:get(entity, ctype) + new_columns[idx + 1][row_entity] = if value == nil then NIL else value + end + end + + --- reverse the order of each array + + remotes.count_total_entities:fire( + query_data.outgoing, + query_id, + total_entities + ) + + -- diff the columns and replicate any new values + for column = 1, math.max(#new_columns, #old_columns) do + for row = 1, upto do + local new_value = new_columns[column][row] + local old_value = old_columns[column][row] + + if new_value ~= old_value or typeof(new_value) == "table" then + -- todo: improve replication of the new value + -- ideally, we would figure out if the value is a certain type and needs special replication + -- if we for example determine a value is a string, or table, we cap it at MAX_CHARACTERS + -- or we tostring a couple keys of the table until we reach MAX_CHARACTERS. + -- we wanna be able to replicate every single. value + + local MAX_CHARS = 750 + local str + + if typeof(new_value) == "string" then + str = `"{string.sub(new_value, 1, MAX_CHARS-2)}"` + elseif typeof(new_value) == "table" then + local temp_n = 0 + local temp_b = {} + + for key, value in new_value do + if #temp_b > 0 then + table.insert(temp_b, "; ") + end + + local str_of_v = if type(value) == "string" then `"{value}"` else tostring(value) + local str = `{key}: {str_of_v}` + + if temp_n + #str + 2 > MAX_CHARS then + table.insert(temp_b, "...") + break + else + table.insert(temp_b, str) + end + end + + str = `\{{table.concat(temp_b)}\}` + elseif new_value == NIL then + str = "" -- important distinction, this is still a valid component + elseif new_value == nil then + str = nil -- but this isnt + else + str = string.sub(tostring(new_value), 1, MAX_CHARS-2) + end + + if row == 1 then str = new_value end + remotes.update_query_result:fire( + query_data.outgoing, + query_id, + query_data.frame, + column, + row, + str + ) + end + end + end + + query_data.new_columns = clear_columns(old_columns) + query_data.old_columns = new_columns + query_data.frame += 1 + debug.profileend() + end + + end + +end \ No newline at end of file diff --git a/modules/Jabby/server/systems/replicate_scheduler.luau b/modules/Jabby/server/systems/replicate_scheduler.luau new file mode 100644 index 0000000..71b1d70 --- /dev/null +++ b/modules/Jabby/server/systems/replicate_scheduler.luau @@ -0,0 +1,104 @@ +--!nolint LocalShadow +local hash = require(script.Parent.Parent.Parent.modules.hash_connector) +local queue = require(script.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.modules.remotes) +local reverse_connector = require(script.Parent.Parent.Parent.modules.reverse_connector) +local traffic_check = require(script.Parent.Parent.Parent.modules.traffic_check) +local types = require(script.Parent.Parent.Parent.modules.types) +local public = require(script.Parent.Parent.public) + +return function() + + local connected = {} + + local request_scheduler = queue(remotes.request_scheduler) + local disconnect_scheduler = queue(remotes.disconnect_scheduler) + local schedule_pause = queue(remotes.scheduler_system_pause) + + return function() + + for incoming, id in request_scheduler:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + + local scheduler = public[id] + + if scheduler.class_name ~= "Scheduler" then continue end + if scheduler == nil then continue end + + local outgoing = reverse_connector(incoming) + connected[id] = connected[id] or {} + table.insert(connected[id], outgoing) + -- print("connected") + + for system_id, data in scheduler.system_data do + remotes.scheduler_system_static_update:fire(outgoing, id, system_id, data) + end + + for system_id, frames in scheduler.system_frames do + local frame = frames[1] + if not frame then continue end + remotes.scheduler_system_update:fire(outgoing, id, system_id, frame.i, frame.s) + end + end + + for incoming, id in disconnect_scheduler:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + + if not connected[id] then continue end + + local scheduler_connected = connected[id] + + for i = #scheduler_connected, 1, -1 do + local connector = scheduler_connected[i] + if connector.host ~= incoming.host then continue end + if connector.to_vm ~= incoming.from_vm then continue end + scheduler_connected[i] = scheduler_connected[#scheduler_connected] + scheduler_connected[#scheduler_connected] = nil + break + end + end + + for incoming, id, system, paused in schedule_pause:iter() do + if not traffic_check.check_no_wl(incoming.host) then continue end + local scheduler: types.Scheduler = public[id] + + if not scheduler then return end + + scheduler:set_system_data(system, { + paused = paused + }) + end + + for id, connected in connected do + local scheduler: types.Scheduler = public[id] + if #connected == 0 then continue end + + for system_id in scheduler.system_data_updated do + local map = {} + local data = scheduler.system_data[system_id] + + for _, connector in connected do + if map[hash(connector)] then continue end + map[hash(connector)] = true + remotes.scheduler_system_static_update:fire(connector, id, system_id, data) + end + + scheduler.system_data_updated[system_id] = nil + end + + for system_id, frames in scheduler.system_frames_updated do + local map = {} + for frame in frames do + for _, connector in connected do + if map[hash(connector)] then continue end + map[hash(connector)] = true + remotes.scheduler_system_update:fire(connector, id, system_id, frame.i, frame.s) + end + end + table.clear(frames) + end + + end + + end +end \ No newline at end of file diff --git a/modules/Jabby/server/systems/replicate_system_watch.luau b/modules/Jabby/server/systems/replicate_system_watch.luau new file mode 100644 index 0000000..025ede2 --- /dev/null +++ b/modules/Jabby/server/systems/replicate_system_watch.luau @@ -0,0 +1,175 @@ +--[[ + +Handles the API for the system watch. +Users will be able to add watches to their systems to track changes. + +Users will be able to learn about what actions a system performs on a jecs world +through this. + +Hooked API's: + +component() +entity() +remove() +clear() +delete() +add() +set() + +]] + +local jecs = require(script.Parent.Parent.Parent.jecs) +local hash_connector = require(script.Parent.Parent.Parent.modules.hash_connector) +local lon = require(script.Parent.Parent.Parent.modules.lon) +local queue = require(script.Parent.Parent.Parent.modules.queue) +local remotes = require(script.Parent.Parent.Parent.modules.remotes) +local reverse_connector = require(script.Parent.Parent.Parent.modules.reverse_connector) +local types = require(script.Parent.Parent.Parent.modules.types) +local public = require(script.Parent.Parent.public) +local watch = require(script.Parent.Parent.watch) + +local NIL = watch.NIL + +return function() + + local stored_watches = {} + local connected_watches = {} + + local function create_watch_for_id( + scheduler: types.Scheduler, + system: types.SystemId, + watch_id: number + ) + local watch = scheduler:create_watch_for_system(system) + stored_watches[watch_id] = watch + end + + local function send_watch_data_to(host: types.OutgoingConnector, watch_id: number, frame: number) + local map_worlds_to_name = {} + local watch = stored_watches[watch_id] + local frames = watch.watch.frames + local data = frames[frame] + + if not data then + remotes.update_watch_data:fire(host, watch_id, frame, nil) + return + end + + for _, world in ipairs(public) do + if world.world == nil then continue end + map_worlds_to_name[world.world] = jecs.Name + end + + local to_send = { + types = data.types, + entities = data.entities, + component = table.clone(data.component), + values = table.clone(data.values) + } + + for idx, ctype in to_send.component do + local world = data.worlds[idx] + to_send.component[idx] = world:get(ctype, map_worlds_to_name[world]) or ctype + end + + for idx, value in to_send.values do + if value == NIL then to_send.values[idx] = "" end + to_send.values[idx] = lon.output(value, false) + end + + remotes.update_watch_data:fire(host, watch_id, frame, to_send :: any) + end + + local function remove_watch_id(watch_id: number) + if not stored_watches[watch_id] then return end + stored_watches[watch_id].untrack() + stored_watches[watch_id] = nil + connected_watches[watch_id] = nil + end + + local function start_record_watch(watch_id: number) + local watch = stored_watches[watch_id] + watch.active = true + end + + local function stop_record_watch(watch_id: number) + local watch = stored_watches[watch_id] + watch.active = false + end + + local function connect_watch(host: types.OutgoingConnector, watch_id: number) + connected_watches[watch_id] = connected_watches[watch_id] or {} + connected_watches[watch_id][hash_connector(host)] = host + + local watch = stored_watches[watch_id] + for i, frame in watch.watch.frames do + remotes.update_overview:fire(host, watch_id, i, #frame.types) + end + end + + local function disconnect_watch(host: types.OutgoingConnector, watch_id: number) + if not connected_watches[watch_id] then return end + connected_watches[watch_id][hash_connector(host)] = nil + end + + local request_create_watch = queue(remotes.create_watch) + local request_remove_watch = queue(remotes.remove_watch) + local request_watch_data = queue(remotes.request_watch_data) + local request_stop_watch = queue(remotes.stop_watch) + local request_record_watch = queue(remotes.start_record_watch) + local request_connect_watch = queue(remotes.connect_watch) + local request_disconnect_watch = queue(remotes.disconnect_watch) + -- local set_lon_enabled = queue(remotes.set_lon_enabled) + + return function() + + for from, scheduler_id, system, watch_id in request_create_watch:iter() do + local scheduler = public[scheduler_id] + + if scheduler.class_name ~= "Scheduler" then continue end + if scheduler == nil then continue end + + create_watch_for_id(scheduler, system, watch_id) + end + + for from, watch_id in request_stop_watch:iter() do + stop_record_watch(watch_id) + end + + for from, watch_id in request_remove_watch:iter() do + remove_watch_id(watch_id) + end + + for from, watch_id, frame in request_watch_data:iter() do + send_watch_data_to( + reverse_connector(from), + watch_id, + frame + ) + end + + for from, watch_id in request_record_watch:iter() do + start_record_watch(watch_id) + end + + for from, watch_id in request_connect_watch:iter() do + connect_watch(reverse_connector(from), watch_id) + end + + for from, watch_id in request_disconnect_watch:iter() do + disconnect_watch(from, watch_id) + end + + for watch_id, hosts in connected_watches do + local watch = stored_watches[watch_id] + local current_frame = watch.watch.frame + local frame_data = watch.watch.frames[current_frame] or {types = {}} + local changes = #frame_data.types + + for _, host in hosts do + remotes.update_overview:fire(host, watch_id, current_frame, changes) + end + end + + end +end \ No newline at end of file diff --git a/modules/Jabby/server/watch.luau b/modules/Jabby/server/watch.luau new file mode 100644 index 0000000..4cff3ba --- /dev/null +++ b/modules/Jabby/server/watch.luau @@ -0,0 +1,129 @@ +local types = require(script.Parent.Parent.modules.types) +local world_hook = require(script.Parent.world_hook) + +local NIL = newproxy() + +type ChangeTypes = "remove" | "clear" | "delete" | "add" | "set" | "entity" | "component" +type Changes = types.WatchLoggedChanges + +export type SystemWatch = { + --- enables Lua Object Notation. + --- incurs a significant performance penalty. + enable_lon: boolean, + --- the current frame to process + frame: number, + + frames: {[number]: Changes} +} + +local function create_changes() + return { + types = {}, + entities = {}, + component = {}, + values = {}, + worlds = {} + } +end + +local function step_watch(watch: SystemWatch) + watch.frame += 1 + watch.frames[watch.frame] = create_changes() +end + +local function track_watch(watch: SystemWatch) + + local hooks = { + + world_hook.hook_onto("remove", function(self, id, component) + local frame = watch.frames[watch.frame] + + table.insert(frame.types, "remove") + table.insert(frame.entities, id) + table.insert(frame.component, component) + table.insert(frame.values, NIL) + table.insert(frame.worlds, self) + end), + + world_hook.hook_onto("clear", function(self, id) + local frame = watch.frames[watch.frame] + + table.insert(frame.types, "clear") + table.insert(frame.entities, id) + table.insert(frame.component, NIL) + table.insert(frame.values, NIL) + table.insert(frame.worlds, self) + end), + + world_hook.hook_onto("delete", function(self, id) + local frame = watch.frames[watch.frame] + + table.insert(frame.types, "delete") + table.insert(frame.entities, id) + table.insert(frame.component, NIL) + table.insert(frame.values, NIL) + table.insert(frame.worlds, self) + end), + + world_hook.hook_onto("add", function(self, id, component) + local frame = watch.frames[watch.frame] + + table.insert(frame.types, "add") + table.insert(frame.entities, id) + table.insert(frame.component, component) + table.insert(frame.values, NIL) + table.insert(frame.worlds, self) + end), + + world_hook.hook_onto("set", function(self, entity, component, value) + if self:has(entity, component) then + local frame = watch.frames[watch.frame] + + table.insert(frame.types, "change") + table.insert(frame.entities, entity) + table.insert(frame.component, component) + table.insert(frame.values, value) + table.insert(frame.worlds, self) + else + local frame = watch.frames[watch.frame] + + table.insert(frame.types, "move") + table.insert(frame.entities, entity) + table.insert(frame.component, component) + table.insert(frame.values, value) + table.insert(frame.worlds, self) + end + end) + + } + + --- stops all hooks + local function stop_hook() + for _, destroy in hooks do + destroy() + end + end + + return stop_hook +end + +local function create_watch() + local watch: SystemWatch = { + enable_lon = false, + + frame = 0, + frames = {} + } + + return watch +end + +return { + + create_watch = create_watch, + track_watch = track_watch, + step_watch = step_watch, + + NIL = NIL + +} \ No newline at end of file diff --git a/modules/Jabby/server/world_hook.luau b/modules/Jabby/server/world_hook.luau new file mode 100644 index 0000000..a43fe52 --- /dev/null +++ b/modules/Jabby/server/world_hook.luau @@ -0,0 +1,75 @@ + +local public = require(script.Parent.public) + +local function i_hook_onto(key: string, hooks: {(...any) -> ()}) + + local to_unhook = {} + for _, world_data in ipairs(public) do + local world = world_data.world + if not world then continue end + + local method: any = world[key] + + assert(typeof(method) == "function", "can only hook onto functions") + + -- create a new wrapper function + local function run_hook(...) + for _, hook in hooks do + hook(...) + end + return method(...) + end + + -- print(debug.info(world[key], "s")) + world[key] = run_hook + to_unhook[world] = method + end + + return function() + for world, method in to_unhook do + world[key] = method + end + end +end + +local hooks = {} + +local function find_swap_pop(list: {T}, value: T) + local idx = table.find(list, value) + if not idx then return end + list[idx] = list[#list] + list[#list] = nil +end + +local function hook_onto(key: string, hook: (...any) -> ()) + + if hooks[key] == nil then + local callbacks = {} + local cleanup = i_hook_onto(key, callbacks) + hooks[key] = { + cleanup = cleanup, + callbacks = callbacks + } + end + + local hook_info = hooks[key] + local dead = false + table.insert(hook_info.callbacks, hook) + + local function unhook() + if dead then return end + dead = true + find_swap_pop(hook_info.callbacks, hook) + + if hook_info.callbacks[1] == nil then + hook_info.cleanup() + hooks[key] = nil + end + end + + return unhook +end + +return { + hook_onto = hook_onto +} \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/accordion.luau b/modules/Jabby/ui/components/display/accordion.luau new file mode 100644 index 0000000..954884e --- /dev/null +++ b/modules/Jabby/ui/components/display/accordion.luau @@ -0,0 +1,121 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) +local container = require(script.Parent.Parent.util.container) +local padding = require(script.Parent.Parent.util.padding) +local typography = require(script.Parent.typography) + +local create = vide.create +local source = vide.source +local changed = vide.changed +local spring = vide.spring + +local CHEVRON_DOWN = "rbxassetid://10709790948" +local CHEVRON_UP = "rbxassetid://10709791523" + +type can = T | () -> T +type props = { + text: can, + expanded: () -> boolean, + set_expanded: (boolean) -> (), + + [any]: any +} + +return function(props: props) + + local gui_state = source(Enum.GuiState.Idle) + local container_size = source(Vector2.zero) + + return container { + Name = "Accordion", + Size = spring(function() + if props.expanded() == false then return UDim2.new(1, 0, 0, 32) end + return UDim2.new(1, 0, 0, 40 + container_size().Y) + end, 0.1), + ClipsDescendants = true, + + create "ImageButton" { + Name = "Accordion", + AutoLocalize = false, + Size = UDim2.new(1, 0, 0, 32), + + BackgroundColor3 = spring(function() + return if gui_state() == Enum.GuiState.Press then + theme.bg[-1]() + elseif gui_state() == Enum.GuiState.Hover then + theme.bg[3]() + else + theme.bg[0]() + end, 0.1), + + padding {x = UDim.new(0, 8)}, + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalFlex = Enum.UIFlexAlignment.SpaceBetween, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8) + }, + + create "UICorner" { + CornerRadius = UDim.new(0, 8) + }, + + container { + Size = UDim2.fromOffset(16, 16), + create "ImageLabel" { + Size = UDim2.fromOffset(16, 16), + BackgroundTransparency = 1, + AutoLocalize = false, + Image = CHEVRON_DOWN, + + Rotation = spring(function() + return if props.expanded() then 180 else 0 + end, 0.1) + }, + }, + + typography { + size = UDim2.fromScale(0, 1), + text = props.text, + truncate = Enum.TextTruncate.SplitWord, + xalignment = Enum.TextXAlignment.Left, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + } + }, + + Activated = function() + props.set_expanded(not props.expanded()) + end, + + changed("GuiState", gui_state) + + }, + + container { + Name = "Children", + + Position = UDim2.fromOffset(0, 40), + AutomaticSize = Enum.AutomaticSize.None, + Size = function() + return UDim2.new(1, 0, 0, container_size().Y) + end, + BackgroundColor3 = theme.bg[3], + + ClipsDescendants = true, + + container { + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + + unpack(props), + + changed("AbsoluteSize", container_size) + } + + } + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/background.luau b/modules/Jabby/ui/components/display/background.luau new file mode 100644 index 0000000..77d7a71 --- /dev/null +++ b/modules/Jabby/ui/components/display/background.luau @@ -0,0 +1,46 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) + +local create = vide.create +local read = vide.read + +type can = (() -> T) | T + +type Background = { + position: can?, + size: can?, + anchorpoint: can?, + automaticsize: can?, + + layoutorder: can?, + zindex: can?, + + depth: can?, + accent: can?, + + [number]: any +} + +return function(props: Background) + + return create "Frame" { + Position = props.position, + Size = props.size or UDim2.fromScale(1, 1), + AnchorPoint = props.anchorpoint, + AutomaticSize = props.automaticsize, + AutoLocalize = false, + + LayoutOrder = props.layoutorder, + + ZIndex = props.zindex, + + BackgroundColor3 = function() + return + if read(props.accent) then theme.acc[read(props.depth) or 0]() + else theme.bg[read(props.depth) or 0]() + end, + + unpack(props) + + } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/checkbox.luau b/modules/Jabby/ui/components/display/checkbox.luau new file mode 100644 index 0000000..a509d09 --- /dev/null +++ b/modules/Jabby/ui/components/display/checkbox.luau @@ -0,0 +1,64 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local anim = require(script.Parent.Parent.Parent.util.anim) +local theme = require(script.Parent.Parent.Parent.util.theme) + +local create = vide.create +local read = vide.read + +type can = (() -> T) | T + +type Background = { + position: can?, + size: can?, + anchorpoint: can?, + automaticsize: can?, + + layoutorder: can?, + zindex: can?, + + checked: can, + + [number]: any +} + +return function(props: Background) + + return create "Frame" { + Position = props.position, + Size = props.size or UDim2.fromOffset(24, 24), + AnchorPoint = props.anchorpoint or Vector2.new(0.5, 0.5), + AutomaticSize = props.automaticsize, + AutoLocalize = false, + + LayoutOrder = props.layoutorder, + + ZIndex = props.zindex, + + BackgroundColor3 = anim(function() + return if read(props.checked) then theme.acc[3]() else theme.bg[1]() + end), + + create "UIStroke" { + Color = function() + return if read(props.checked) then theme.acc[0]() else theme.bg[-3]() + end + }, + + create "UICorner" { + CornerRadius = UDim.new(0, 4) + }, + + create "ImageLabel" { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + + Image = "rbxassetid://100188624502987", + ImageTransparency = anim(function() + return if read(props.checked) then 0 else 1 + end) + }, + + unpack(props) + + } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/divider.luau b/modules/Jabby/ui/components/display/divider.luau new file mode 100644 index 0000000..0a2f0ee --- /dev/null +++ b/modules/Jabby/ui/components/display/divider.luau @@ -0,0 +1,23 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) + +local create = vide.create +local read = vide.read + +type can = T | () -> T +type props = { + thickness: can?, + position: can?, +} + +return function(props: props) + + return create "Frame" { + BackgroundColor3 = theme.bg[-2], + Position = props.position, + AutoLocalize = false, + Size = function() + return UDim2.new(1, 0, 0, read(props.thickness) or 1) + end, + } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/pages.luau b/modules/Jabby/ui/components/display/pages.luau new file mode 100644 index 0000000..b45b756 --- /dev/null +++ b/modules/Jabby/ui/components/display/pages.luau @@ -0,0 +1,118 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local anim = require(script.Parent.Parent.Parent.util.anim) +local theme = require(script.Parent.Parent.Parent.util.theme) +local button = require(script.Parent.Parent.interactable.button) +local container = require(script.Parent.Parent.util.container) +local list = require(script.Parent.Parent.util.list) +local padding = require(script.Parent.Parent.util.padding) +local rounded_frame = require(script.Parent.Parent.util.rounded_frame) +local divider = require(script.Parent.divider) +local typography = require(script.Parent.typography) + +local create = vide.create +local source = vide.source +local changed = vide.changed +local indexes = vide.indexes +local untrack = vide.untrack +local cleanup = vide.cleanup + +type can = T | () -> T +type props = { + labels: () -> { + { + title: string, + ui: () -> Instance | {Instance} + } + } +} + +return function(props: props) + + local selected = source(1) + + return list { + justifycontent = Enum.UIFlexAlignment.Fill, + spacing = UDim.new(), + + create "Frame" { + Size = UDim2.new(1, 0, 0, 32), + AutoLocalize = false, + BackgroundColor3 = theme.bg[3], + + divider { + position = UDim2.fromScale(0, 1), + }, + + container { + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal + }, + + indexes(props.labels, function(value, key) + local guistate = source(Enum.GuiState.Idle) + + return rounded_frame { + name = key, + size = UDim2.fromOffset(50, 30), + automaticsize = Enum.AutomaticSize.X, + topleft = UDim.new(0, 4), + topright = UDim.new(0, 4), + + color = function() + return if selected() == key then theme.bg[0]() + elseif guistate() == Enum.GuiState.Idle then theme.bg[3]() + else theme.bg[1]() + end, + + create "TextButton" { + Size = UDim2.fromScale(1, 1), + AutoLocalize = false, + BackgroundTransparency = 1, + + Activated = function() + selected(key) + end, + + typography { + + position = UDim2.fromScale(0.5, 0.5), + anchorpoint = Vector2.new(0.5, 0.5), + + text = function() + return value().title + end, + + textsize = 16 + }, + + + padding { + x = UDim.new(0, 24), + y = UDim.new(0, 2) + }, + + changed("GuiState", guistate) + } + } + end), + }, + + ZIndex = 100, + + }, + + create "Frame" { + Size = UDim2.new(1, 0, 1, 0), + AutoLocalize = false, + + BackgroundColor3 = theme.bg[0], + + function() + return untrack(props.labels()[selected()].ui) + end + + }, + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/pane.luau b/modules/Jabby/ui/components/display/pane.luau new file mode 100644 index 0000000..9a577cb --- /dev/null +++ b/modules/Jabby/ui/components/display/pane.luau @@ -0,0 +1,70 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) +local list = require(script.Parent.Parent.util.list) +local padding = require(script.Parent.Parent.util.padding) +local background = require(script.Parent.background) +local typography = require(script.Parent.typography) + +local create = vide.create +local read = vide.read +local show = vide.show + +type can = T | () -> T +type props = { + + size: can?, + position: can?, + anchorpoint: can?, + layoutorder: can?, + automaticsize: can?, + + name: can?, + + [number]: any + +} + +return function(props: props) + + return create "Frame" { + Name = props.name, + Size = props.size or UDim2.fromScale(1, 0), + Position = props.position, + AnchorPoint = props.anchorpoint, + LayoutOrder = props.layoutorder, + AutomaticSize = props.automaticsize or Enum.AutomaticSize.Y, + BackgroundColor3 = theme.bg[0], + AutoLocalize = false, + + create "UICorner" { + CornerRadius = UDim.new(0, 8) + }, + create "UIStroke" { + Color = theme.bg[-3] + }, + show(function() + return if read(props.name) then #read(props.name) > 0 else false + end, function() + return background { + size = UDim2.new(), + position = UDim2.fromOffset(4, -16), + automaticsize = Enum.AutomaticSize.XY, + + typography { + text = props.name, + disabled = true, + + textsize = 14 + }, + + padding {x = UDim.new(0, 2), y = UDim.new()} + } + end), + + padding {}, + list { + unpack(props) + } + + } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/resizeable_bar.luau b/modules/Jabby/ui/components/display/resizeable_bar.luau new file mode 100644 index 0000000..db40a8a --- /dev/null +++ b/modules/Jabby/ui/components/display/resizeable_bar.luau @@ -0,0 +1,207 @@ +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) +local padding = require(script.Parent.Parent.util.padding) +local rounded_frame = require(script.Parent.Parent.util.rounded_frame) +local typography = require(script.Parent.typography) + +local create = vide.create +local source = vide.source +local derive = vide.derive +local effect = vide.effect +local cleanup = vide.cleanup +local indexes = vide.indexes +local changed = vide.changed +local untrack = vide.untrack + +local MAX_PIXELS_OFFSET = 32 + +local BEFORE = source(0) +local AFTER = source(1) + +type ResizeableBar = { + meaning: () -> { string }, + min_sizes: (() -> { vide.source? })?, + sizes: vide.source<{ vide.source }>, + suggested_sizes: { number }?, + + splits: (vide.source<{ vide.source }>)?, + base_splits: { number }? + +} + +return function(props: ResizeableBar) + local meaning = props.meaning + local sizes = props.sizes + local min_sizes = props.min_sizes or source({}) :: never + local suggested_sizes = props.suggested_sizes or {} + + local absolute_size = source(Vector2.one) + local absolute_position = source(Vector2.one) + + local total = derive(function() + return #meaning() + end) + + local splits = props.splits or source {} + local total_columns = derive(function() + return #props.meaning() + end) + + effect(function(previous) + local new = {} + + for i = 1, total_columns() - 1 do + local old_split = vide.read(previous and previous[i] or nil) + new[i] = source(math.min(if old_split and old_split ~= 1 then old_split else suggested_sizes[i] or 1, i / total_columns())) + end + + splits(new) + return new + end) + + for i, split in (props.base_splits :: never) or {} do + splits()[i](split) + end + + local function get_size(index: number) + local split_before = splits()[index - 1] or BEFORE :: never + local split_after = splits()[index] or AFTER :: never + + local size = split_after() - split_before() + return size + end + + local function get_min_size(i: number) + local min_size = min_sizes()[i] + return min_size and min_size() or 0.025 + end + + effect(function() + local new = setmetatable({}, { + __index = function() + return function() return 0 end + end, + }) + + for i = 1, total() do + min_sizes()[i] = min_sizes()[i] or source(0.025) + untrack(function() + new[i] = derive(function() + return get_size(i) + end) + end) + end + + sizes(new :: any) + end) + + local down = false + local updating = 0 + + return rounded_frame { + size = function() + return UDim2.new(1, 0, 0, 32) + end, + topleft = UDim.new(0, 8), + topright = UDim.new(0, 8), + color = theme.bg[1], + + create "TextButton" { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + AutoLocalize = false, + Text = "", + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + Padding = UDim.new(0, 0) + }, + + indexes(meaning, function(column, i) + return typography { + size = function() + return UDim2.fromScale(get_size(i), 1) + end, + automaticsize = Enum.AutomaticSize.None, + text = function() + return column() or "" + end, + xalignment = Enum.TextXAlignment.Left, + truncate = Enum.TextTruncate.AtEnd, + header = true, + textsize = 18, + + padding {x = UDim.new(0, 8)} + } + end), + + changed("AbsoluteSize", absolute_size), + changed("AbsolutePosition", absolute_position), + + MouseButton1Down = function(x: number) + -- find the nearest split + x -= absolute_position().X + local absolute_size = absolute_size() + local nearest = -1 + for i, location in splits() do + local absolute_x = absolute_size.X * location() + if math.abs(x - absolute_x) > MAX_PIXELS_OFFSET then continue end + + nearest = i + end + + down = nearest ~= -1 + updating = nearest + end, + + MouseButton1Up = function() + down = false + end, + + cleanup(RunService.Heartbeat:Connect(function() + local x = UserInputService:GetMouseLocation().X + + x -= absolute_position().X + if down == false then return end + down = UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton1) == true + + local relative = x / absolute_size().X + local current = splits()[updating]() + local left_to_move = relative - current + + if left_to_move > 0 then + for i = updating, total() - 1, 1 do + local min_size = get_min_size(i + 1) + local size = get_size(i + 1) + + local new_size = math.max(size - left_to_move, min_size) + local difference = size - new_size + + splits()[i](splits()[i]() + difference) + left_to_move -= difference + + if left_to_move == 0 then break end + end + else + for i = updating, 1, -1 do + local min_size = get_min_size(i) + + local size = math.max(get_size(i), min_size) -- this is changing, which it isnt supposed to do + + local new_size = math.max(size + left_to_move, min_size) + local difference = new_size - size + + splits()[i](splits()[i]() + difference) + --assert((new_size + difference) == get_size(i - 1)) + left_to_move -= difference + + if left_to_move == 0 then break end + end + end + end)), + } + + } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/scroll_frame.luau b/modules/Jabby/ui/components/display/scroll_frame.luau new file mode 100644 index 0000000..cab5be8 --- /dev/null +++ b/modules/Jabby/ui/components/display/scroll_frame.luau @@ -0,0 +1,22 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) + +local create = vide.create + +type props = vide.vScrollingFrame + +return function(props: props) + + return create "ScrollingFrame" { + AutoLocalize = false, + ScrollBarThickness = 6, + ScrollBarImageColor3 = theme.fg_on_bg_low[0], + CanvasSize = UDim2.new(), + + BackgroundTransparency = 1, + + props + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/snapping.luau b/modules/Jabby/ui/components/display/snapping.luau new file mode 100644 index 0000000..8186829 --- /dev/null +++ b/modules/Jabby/ui/components/display/snapping.luau @@ -0,0 +1,123 @@ +local UserInputService = game:GetService("UserInputService") + +local vide = require(script.Parent.Parent.Parent.Parent.vide) + +local create = vide.create +local source = vide.source +local cleanup = vide.cleanup +local effect = vide.effect +local action = vide.action +local changed = vide.changed +local untrack = vide.untrack +local read = vide.read + +type can = T | () -> T + +type snap_area = { + zindex: can?, + snapped: (boolean) -> () +} + +type snappable = { + --- tells the object that you are dragging it + dragging: () -> boolean, + --- allows making the widget float by itself without being anchored to anything + allow_floating: can, + + --- callbacks that update the position and size + snapped: (boolean) -> (), + position: (UDim2) -> (), + size: (UDim2) -> () +} + +local function in_bounds(mpos: Vector2, pos: Vector2, size: Vector2) + return mpos.X >= pos.X and mpos.X <= pos.X + size.X and mpos.Y >= pos.Y and mpos.Y <= pos.Y + size.Y +end + +return function() + + local snap_areas = {} + local mouse_position = source(Vector2.zero) + + local function snap_area(props: snap_area) + local position = source(Vector2.zero) + local size = source(Vector2.zero) + local docked = source(false) + + return create "Frame" { + Name = "SnapArea", + AutoLocalize = false, + Size = UDim2.fromScale(1, 1), + + BackgroundTransparency = 1, + + changed("AbsoluteSize", size), + changed("AbsolutePosition", position), + + action(function(ref) + snap_areas[ref] = { + position = position, + docked = docked, + size = size, + zindex = props.zindex or 0 + } + + cleanup(function() + snap_areas[ref] = nil + end) + end) + } + end + + local function snappable(props: snappable) + local snapped_to = source() + + effect(function() + if props.dragging() == false then return end + local mpos = mouse_position() + + untrack(function() + if snapped_to() then snapped_to().docked(false) end + + local snap_to + + for _, data in snap_areas do + if not in_bounds(mpos, data.position(), data.size()) then continue end + if snap_to and read(data.zindex) <= read(snap_to.zindex) then continue end + snap_to = data + end + + if not snap_to and read(props.allow_floating) == false then return end + if snap_to and snap_to.docked() then return end + if snap_to then snap_to.docked(true) end + + snapped_to(snap_to) + end) + end) + + effect(function() + props.snapped(if snapped_to() then true else false) + end) + + effect(function() + if not snapped_to() then return end + local data = snapped_to() + local pos = data.position() + local size = data.size() + + props.position(UDim2.fromOffset(pos.X, pos.Y)) + props.size(UDim2.fromOffset(size.X, size.Y)) + end) + end + + cleanup(UserInputService.InputChanged:Connect(function(input) + if input.UserInputType ~= Enum.UserInputType.MouseMovement then return end + mouse_position(Vector2.new(input.Position.X, input.Position.Y)) + end)) + + return { + snap_area = snap_area, + snappable = snappable + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/tablesheet.luau b/modules/Jabby/ui/components/display/tablesheet.luau new file mode 100644 index 0000000..fa19e87 --- /dev/null +++ b/modules/Jabby/ui/components/display/tablesheet.luau @@ -0,0 +1,179 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) +local button = require(script.Parent.Parent.interactable.button) +local rounded_frame = require(script.Parent.Parent.util.rounded_frame) +local virtualscroller = require(script.Parent.Parent.util.virtualscroller) +local resizeable_bar = require(script.Parent.resizeable_bar) +local scroll_frame = require(script.Parent.scroll_frame) + +local create = vide.create +local source = vide.source +local derive = vide.derive +local indexes = vide.indexes + +type can = T | () -> T +type table = { + size: can?, + suggested_column_sizes: { number }?, + + base_splits: { number }?, + columns: () -> {{any}}, + + on_click: (column: number, row: number) -> (), + on_click2: (column: number, row: number) -> (), + read_value: (column: number, row: number) -> string, + + below: {[number]: any}?, + + [number]: any +} + +return function(props: table) + local sizes = source({}) + local splits = source({}) + + local meaning = derive(function() + local t = {} + + for i, column in props.columns() do + t[i] = column[1] + end + + return t + end) + + local function get_size(index: number) + local split_before = splits()[index - 1] or source(0) :: never + local split_after = splits()[index] or source(1) :: never + + local size = split_after() - split_before() + return size + end + + return scroll_frame { + Size = props.size or UDim2.new(1, 0, 0, 8 * 32), + CanvasSize = function() + return UDim2.new(1, 0) + end, + + create "UIListLayout" { + VerticalFlex = Enum.UIFlexAlignment.SpaceEvenly + }, + + resizeable_bar { + meaning = meaning, + sizes = sizes, + splits = splits, + base_splits = props.base_splits, + suggested_sizes = props.suggested_column_sizes + }, + + create "Folder" { + + indexes(meaning, function(_, i) + return create "Frame" { + Size = UDim2.new(0, 1, 1, -32), + AutoLocalize = false, + Position = function() + local pos = splits()[i] + return if not pos then UDim2.fromScale(0, 0) else UDim2.fromScale(pos(), 0) + end, + + BackgroundColor3 = theme.bg[-1], + + ZIndex = 100 + } + end) + }, + + virtualscroller { + size = UDim2.fromScale(1, 0), + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Grow, + }, + + { + BackgroundColor3 = theme.bg[0], + VerticalScrollBarInset = Enum.ScrollBarInset.None, + BackgroundTransparency = 0, + }, + + item_size = 32, + item = function(index) + + return create "Frame" { + Size = UDim2.new(1, 0, 0, 32), + AutoLocalize = false, + + BackgroundColor3 = theme.bg[2], + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + Padding = UDim.new(0, 0) + }, + + create "UIStroke" { + Color = theme.bg[-1], + }, + + indexes(props.columns, function(column, i) + return button { + size = function() + local column_size = get_size(i) + return UDim2.new(column_size, 0, 0, 32) + end, + + text = function() + return props.read_value(i, index() + 1) or "" + end, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center + }, + + xalignment = Enum.TextXAlignment.Left, + + corner = false, + stroke = false, + code = true, + + activated = function() + props.on_click(i, index() + 2) + end, + + mouse2 = function() + props.on_click2(i, index() + 2) + end + } :: Instance + end) + + } + + end, + + max_items = function() + local value = (props.columns()[1] ~= nil and #props.columns()[1] or 0) + return value - 1 + end, + + + }, + + rounded_frame { + size = function() + return UDim2.new(1, 0, 0, 32) + end, + + color = theme.bg[1], + + props.below, + + bottomleft = UDim.new(0, 8), + bottomright = UDim.new(0, 8), + }, + + unpack(props) + } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/typography.luau b/modules/Jabby/ui/components/display/typography.luau new file mode 100644 index 0000000..203139a --- /dev/null +++ b/modules/Jabby/ui/components/display/typography.luau @@ -0,0 +1,86 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local anim = require(script.Parent.Parent.Parent.util.anim) +local theme = require(script.Parent.Parent.Parent.util.theme) + +local create = vide.create +local read = vide.read + +type can = T | () -> T +type props = { + + size: can?, + position: can?, + anchorpoint: can?, + automaticsize: can?, + + accent: can?, + + xalignment: can?, + yalignment: can?, + truncate: can?, + wrapped: can?, + + header: can?, + code: can?, + disabled: can?, + + text: can, + textsize: can?, + + visible: can?, + + [number]: any + +} + +return function(props: props) + + local function font() + return if read(props.code) then theme.code else theme.font + end + + local function fg() + local accent = read(props.accent) + local disabled = read(props.disabled) + + return if accent then + if disabled then theme.fg_on_acc_low[0]() + else theme.fg_on_acc_high[0]() + else + if disabled then theme.fg_on_bg_low[0]() + else theme.fg_on_bg_high[0]() + end + + return create "TextLabel" { + + Size = props.size, + Position = props.position, + AnchorPoint = props.anchorpoint, + AutomaticSize = props.automaticsize or Enum.AutomaticSize.XY, + AutoLocalize = false, + + TextXAlignment = props.xalignment, + TextYAlignment = props.yalignment, + TextTruncate = props.truncate, + + BackgroundTransparency = 1, + + Text = props.text, + + TextSize = props.textsize or function() + return if read(props.header) then theme.header else theme.body + end, + TextWrapped = props.wrapped, + FontFace = function() + return if read(props.header) then + Font.new(font().Family, Enum.FontWeight.Bold) + else font() + end, + TextColor3 = anim(fg), + + Visible = props.visible, + + unpack(props) + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/widget/borders.luau b/modules/Jabby/ui/components/display/widget/borders.luau new file mode 100644 index 0000000..018dac8 --- /dev/null +++ b/modules/Jabby/ui/components/display/widget/borders.luau @@ -0,0 +1,229 @@ +local GuiService = game:GetService("GuiService") +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") + +local vide = require(script.Parent.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.Parent.util.theme) +local container = require(script.Parent.Parent.Parent.util.container) + +local create = vide.create +local source = vide.source +local spring = vide.spring +local changed = vide.changed +local cleanup = vide.cleanup + +type Source = vide.Source +type props = { + + resize_range: number, + min_size: Vector2, + + can_resize_left: Source, + can_resize_right: Source, + can_resize_bottom: Source, + can_resize_top: Source, + + resizing: () -> boolean, +} + +local function xpos(s: () -> number) + return function() + return Vector2.new(s(), 0) + end +end + +local function ypos(s: () -> number) + return function() + return Vector2.new(0, s()) + end +end + +return function(props: props) + local RESIZE_RANGE = props.resize_range + local MIN_SIZE = props.min_size + + local can_resize_left = props.can_resize_left + local can_resize_right = props.can_resize_right + local can_resize_bottom = props.can_resize_bottom + local can_resize_top = props.can_resize_top + local resizing = props.resizing + + local absolute_size = source(Vector2.new(1, 1)) + local absolute_position = source(Vector2.zero) + + local thickness = 4 + local border_selected = theme.acc[8] + local gradient = NumberSequence.new({ + NumberSequenceKeypoint.new(0, 1), + NumberSequenceKeypoint.new(0.25, 1), + NumberSequenceKeypoint.new(0.5, 0), + NumberSequenceKeypoint.new(0.75, 1), + NumberSequenceKeypoint.new(1, 1), + }) + + local x = source(0) + local y = source(0) + + cleanup(RunService.Heartbeat:Connect(function() + local mposition = UserInputService:GetMouseLocation() + local top_inset, bottom_inset = GuiService:GetGuiInset() + mposition += - top_inset - bottom_inset + + if MIN_SIZE.X ~= absolute_size().X or not resizing() then x(mposition.X) end + if MIN_SIZE.Y ~= absolute_size().Y or not resizing() then y(mposition.Y) end + end)) + + cleanup(RunService.RenderStepped:Connect(function() + local mposition = UserInputService:GetMouseLocation() + local top_inset, bottom_inset = GuiService:GetGuiInset() + mposition += - top_inset - bottom_inset + local x, y = mposition.X, mposition.Y + + if resizing() then return end + + local left = absolute_position().X + local top = absolute_position().Y + local right = left + absolute_size().X + local bottom = top + absolute_size().Y + + local topleft = absolute_position() - Vector2.new(RESIZE_RANGE, RESIZE_RANGE) + local bottomright = absolute_position() + absolute_size() + Vector2.new(RESIZE_RANGE, RESIZE_RANGE) + + -- perform AABB to check if the cursor is in range + local within_bounds = x > topleft.X and y > topleft.Y and x < bottomright.X and y < bottomright.Y + + can_resize_top(y > top - RESIZE_RANGE and y < top and within_bounds) + can_resize_left(x < left + RESIZE_RANGE and x > left - RESIZE_RANGE and within_bounds) + can_resize_bottom(y < bottom + RESIZE_RANGE and y > bottom - RESIZE_RANGE and within_bounds) + can_resize_right(x < right + RESIZE_RANGE and x > right - RESIZE_RANGE and within_bounds) + end)) + + return { + + changed("AbsoluteSize", function(value: Vector2) + if value.Magnitude == 0 then return end + absolute_size(value) + end), + changed("AbsolutePosition", absolute_position), + + container { + + Name = "Left", + + Position = UDim2.fromScale(0, 0.5), + Size = UDim2.new(0, thickness, 1, thickness * 2), + AnchorPoint = Vector2.new(1, 0.5), + + BackgroundColor3 = border_selected, + + BackgroundTransparency = spring(function() + return can_resize_left() and 0 or 1 + end, 0.2), + + ZIndex = 1000, + + create "UIGradient" { + + Rotation = 90, + + Transparency = gradient, + Offset = ypos(spring(function() + return (y() - absolute_position().Y - absolute_size().Y / 2) / absolute_size().Y + end, 0.1)), + }, + + create "UICorner" { + CornerRadius = UDim.new(0, 4) + }, + }, + + container { + + Name = "Right", + + Position = UDim2.fromScale(1, 0.5), + Size = UDim2.new(0, thickness, 1, thickness * 2), + AnchorPoint = Vector2.new(0, 0.5), + + BackgroundColor3 = border_selected, + + BackgroundTransparency = spring(function() + return can_resize_right() and 0 or 1 + end, 0.2), + + ZIndex = 1000, + + create "UIGradient" { + + Rotation = 90, + + Transparency = gradient, + Offset = ypos(spring(function() + return (y() - absolute_position().Y - absolute_size().Y / 2) / absolute_size().Y + end, 0.1)), + }, + + create "UICorner" { + CornerRadius = UDim.new(0, 4) + }, + }, + + container { + + Name = "Bottom", + + Position = UDim2.fromScale(0.5, 1), + Size = UDim2.new(1, thickness * 2, 0, thickness), + AnchorPoint = Vector2.new(0.5, 0), + + BackgroundColor3 = border_selected, + + BackgroundTransparency = spring(function() + return can_resize_bottom() and 0 or 1 + end, 0.2), + + ZIndex = 1000, + + create "UIGradient" { + + Transparency = gradient, + Offset = xpos(spring(function() + return (x() - absolute_position().X - absolute_size().X / 2) / absolute_size().X + end, 0.1)), + }, + + create "UICorner" { + CornerRadius = UDim.new(0, 4) + }, + }, + + container { + + Name = "Top", + + Position = UDim2.fromScale(0.5, 0), + Size = UDim2.new(1, thickness * 2, 0, thickness), + AnchorPoint = Vector2.new(0.5, 1), + + BackgroundColor3 = border_selected, + + BackgroundTransparency = spring(function() + return can_resize_top() and 0 or 1 + end, 0.2), + + ZIndex = 1000, + + create "UIGradient" { + + Transparency = gradient, + Offset = xpos(spring(function() + return (x() - absolute_position().X - absolute_size().X / 2) / absolute_size().X + end, 0.1)), + }, + + create "UICorner" { + CornerRadius = UDim.new(0, 4) + }, + }, + } :: { any } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/widget/init.luau b/modules/Jabby/ui/components/display/widget/init.luau new file mode 100644 index 0000000..31ada1f --- /dev/null +++ b/modules/Jabby/ui/components/display/widget/init.luau @@ -0,0 +1,299 @@ +local GuiService = game:GetService("GuiService") +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") + +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) +local container = require(script.Parent.Parent.util.container) +local padding = require(script.Parent.Parent.util.padding) +local shadow = require(script.Parent.Parent.util.shadow) +local divider = require(script.Parent.divider) +local snapping = require(script.Parent.snapping) +local borders = require(script.borders) +local topbar = require(script.topbar) + +local create = vide.create +local source = vide.source +local cleanup = vide.cleanup +local changed = vide.changed +local reference = vide.action +local spring = vide.spring + +type can = T | () -> T +type props = { + + title: can, + subtitle: can?, + min_size: Vector2?, + position: Vector2?, + size: Vector2?, + + bind_to_close: (() -> ())?, + + [number]: any + +} + +local HIGHEST_DISPLAY_ORDER = 100000 +local RESIZE_RANGE = 6 + +local docks + +vide.mount(function() + + docks = snapping() + + return create "ScreenGui" { + Name = "docks", + AutoLocalize = false, + + -- create "Frame" { + -- Size = UDim2.new(1, 0, 0, 200), + + -- BackgroundTransparency = 1, + + -- docks.snap_area {} + -- }, + + -- create "Frame" { + -- Size = UDim2.new(1, 0, 0, 200), + -- Position = UDim2.fromScale(0, 1), + -- AnchorPoint = Vector2.new(0, 1), + -- BackgroundTransparency = 1, + + -- docks.snap_area {} + -- }, + + create "Frame" { + Size = UDim2.new(0, 16, 1, 0), + BackgroundTransparency = 1, + AutoLocalize = false, + + docks.snap_area {} + }, + } + +end, Players.LocalPlayer.PlayerGui) + +return function(props: props) + local min_size = Vector2.new(100, 100):Max(props.min_size or Vector2.zero) + local position = props.position or Vector2.new(32, 32) + local base_size = props.size or min_size * 1.5 + + local x_size = source(math.max(min_size.X, base_size.X)) + local y_size = source(math.max(min_size.Y, base_size.Y)) + local x_position = source(position.X) + local y_position = source(position.Y) + + local offset = source(Vector2.zero) + local dragging = source(false) + local absolute_position = source(Vector2.zero) + local absolute_size = source(Vector2.zero) + + local can_resize_top = source(false) + local can_resize_bottom = source(false) + local can_resize_right = source(false) + local can_resize_left = source(false) + local resizing = source(false) + local ref = source() + local display_order = source(HIGHEST_DISPLAY_ORDER + 1) + HIGHEST_DISPLAY_ORDER += 1 + + local mouse_inside = source(false) + + local top: Vector2 + local bottom: Vector2 + + cleanup(UserInputService.InputEnded:Connect(function(input) + if + input.UserInputType ~= Enum.UserInputType.MouseButton1 + and input.UserInputType ~= Enum.UserInputType.Touch + then + return + end + resizing(false) + dragging(false) + end)) + + cleanup(UserInputService.InputChanged:Connect(function(input: InputObject) + if input.UserInputType ~= Enum.UserInputType.MouseMovement then return end + if not resizing() then return end + + local mposition = UserInputService:GetMouseLocation() + local top_inset, bottom_inset = GuiService:GetGuiInset() + mposition += - top_inset - bottom_inset + local x, y = mposition.X, mposition.Y + + if can_resize_bottom() then y_size(math.max(y - top.Y, min_size.Y)) end + if can_resize_right() then x_size(math.max(x - top.X, min_size.X)) end + if can_resize_top() then + y_size(math.max(bottom.Y - y, min_size.Y)) + y_position(math.min(y, bottom.Y - min_size.Y)) + end + if can_resize_left() then + x_size(math.max(bottom.X - x, min_size.X)) + x_position(math.min(x, bottom.X - min_size.X)) + end + end)) + + cleanup(UserInputService.InputBegan:Connect(function(input) + if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end + if not dragging() then resizing(true) end + + top = absolute_position() + bottom = absolute_position() + absolute_size() + + local player_gui + if Players.LocalPlayer and RunService:IsRunning() then + player_gui = Players.LocalPlayer:WaitForChild("PlayerGui") :: PlayerGui + elseif RunService:IsStudio() and RunService:IsRunning() then + player_gui = game:GetService("CoreGui") :: any + else + return + end + + local objects = player_gui:GetGuiObjectsAtPosition(input.Position.X, input.Position.Y) + if #objects == 0 then return end + if not objects[1]:IsDescendantOf(ref()) then return end + + display_order(HIGHEST_DISPLAY_ORDER + 1) + HIGHEST_DISPLAY_ORDER += 1 + end)) + + cleanup(UserInputService.InputChanged:Connect(function(input: InputObject) + if dragging() == false then return end + if not UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton1) then + dragging(false) + return + end + + local position = UserInputService:GetMouseLocation() + -- local top_inset, bottom_inset = GuiService:GetGuiInset() + -- position += - top_inset - bottom_inset + x_position(position.X + offset().X) + y_position(position.Y + offset().Y) + end)) + + local snapped = source(false) + local snap_size = source(UDim2.new()) + local snap_pos = source(UDim2.new()) + + local function radius() + return if snapped() then UDim.new() else UDim.new(0, 6) + end + + return create "ScreenGui" { + Name = props.title, + AutoLocalize = false, + DisplayOrder = display_order, + reference(ref), + + create "Frame" { + AutoLocalize = false, + Position = function() + return if snapped() then snap_pos() else UDim2.fromOffset(x_position(), y_position()) + end, + + Size = function() + return if snapped() then UDim2.fromOffset(x_size() + 6, snap_size().Y.Offset) else UDim2.fromOffset(x_size() + 6, y_size() + 6) + end, + + Active = true, + + BackgroundColor3 = theme.bg[0], + + MouseMoved = function() + if resizing() then return end + + local mposition = UserInputService:GetMouseLocation() + local top_inset, bottom_inset = GuiService:GetGuiInset() + position += - top_inset - bottom_inset + local x, y = mposition.X, mposition.Y + x -= absolute_position().X + y -= absolute_position().Y + + can_resize_top(y < RESIZE_RANGE) + can_resize_left(x < RESIZE_RANGE) + can_resize_bottom(y > (absolute_size().Y - RESIZE_RANGE)) + can_resize_right(x > (absolute_size().X - RESIZE_RANGE)) + end, + + MouseEnter = function() + mouse_inside(true) + end, + + MouseLeave = function() + if resizing() then return end + if RunService:IsRunning() == false then return end + + mouse_inside(false) + end, + + changed("AbsolutePosition", absolute_position), + changed("AbsoluteSize", absolute_size), + + create "UICorner" { + CornerRadius = radius + }, + + shadow {}, + + borders { + + resize_range = RESIZE_RANGE, + min_size = min_size, + + can_resize_top = can_resize_top, + can_resize_bottom = can_resize_bottom, + can_resize_left = can_resize_left, + can_resize_right = can_resize_right, + + resizing = resizing, + }, + + container { + Size = UDim2.fromScale(1, 1), + + create "UIListLayout" {}, + + topbar { + title = props.title, + subtitle = props.subtitle, + dragging = dragging, + offset = offset, + bind_to_close = props.bind_to_close, + radius = radius + }, + + divider {}, + + container { + Size = UDim2.fromScale(1, 0), + + padding { + x = UDim.new(0, 8), + y = UDim.new(0, 8) + }, + + unpack(props), + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Grow + }, + } + }, + + docks.snappable { + dragging = dragging, + + snapped = snapped, + position = snap_pos, + size = snap_size + } + + }, + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/display/widget/topbar.luau b/modules/Jabby/ui/components/display/widget/topbar.luau new file mode 100644 index 0000000..535efc9 --- /dev/null +++ b/modules/Jabby/ui/components/display/widget/topbar.luau @@ -0,0 +1,150 @@ + +local vide = require(script.Parent.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.Parent.util.theme) +local list = require(script.Parent.Parent.Parent.util.list) +local padding = require(script.Parent.Parent.Parent.util.padding) +local rounded_frame = require(script.Parent.Parent.Parent.util.rounded_frame) +local typography = require(script.Parent.Parent.typography) + +local create = vide.create +local source = vide.source +local changed = vide.changed +local spring = vide.spring +local show = vide.show + +type Source = vide.Source +type props = { + + title: (string | () -> string)?, + subtitle: (string | () -> string)?, + bind_to_close: (() -> ())?, + + radius: () -> UDim, + + dragging: Source, + offset: (new: Vector2) -> (), + + [any]: any, +} + +return function(props: props) + local bind_to_close = props.bind_to_close + local dragging = props.dragging + local offset = props.offset + + local closeable = not not bind_to_close + + local absolute_position = source(Vector2.zero) + + local gui_state = source(Enum.GuiState.Idle) + + return rounded_frame { + + name = "Topbar", + + size = UDim2.new(1, 0, 0, 48), + color = theme.bg[3], + + topleft = props.radius, + topright = props.radius, + + create "ImageButton" { + Size = UDim2.fromScale(1, 1), + AutoLocalize = false, + BackgroundTransparency = 1, + ZIndex = 1000, + + changed("AbsolutePosition", absolute_position), + + MouseButton1Down = function(x, y) + offset(absolute_position() - Vector2.new(x, y)) + dragging(true) + end, + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + HorizontalFlex = Enum.UIFlexAlignment.Fill, + }, + + padding { + x = UDim.new(0, 16) + }, + + list { + spacing = UDim.new(), + + typography { + size = UDim2.fromScale(1, 0), + text = props.title, + + xalignment = Enum.TextXAlignment.Left, + truncate = Enum.TextTruncate.SplitWord, + textsize = 20, + header = true, + }, + + show(function() + return props.subtitle ~= nil + end, function() + return typography { + size = UDim2.fromScale(1, 0), + text = props.subtitle, + bold = true, + + xalignment = Enum.TextXAlignment.Left, + truncate = Enum.TextTruncate.SplitWord, + + textsize = 16, + } + end) + + }, + + show(source(closeable), function() + return create "ImageButton" { + + Size = UDim2.fromOffset(32, 32), + + BackgroundColor3 = spring(function() + return if gui_state() == Enum.GuiState.Hover then + theme.bg[5]() + elseif gui_state() == Enum.GuiState.Press then + theme.bg[0]() + else + theme.bg[3]() + end, 0.1), + + changed("GuiState", gui_state), + + Activated = props.bind_to_close, + + create "UICorner" { + CornerRadius = UDim.new(1, 0) + }, + + create "ImageLabel" { + Size = UDim2.fromOffset(24, 24), + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + AutoLocalize = false, + + BackgroundTransparency = 1, + + ImageColor3 = theme.fg_on_bg_high[3], + + Image = "rbxassetid://10747384394", + + }, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Custom, + ShrinkRatio = 0 + } + + } + end) + + } + + } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/graph/bargraph.luau b/modules/Jabby/ui/components/graph/bargraph.luau new file mode 100644 index 0000000..799c706 --- /dev/null +++ b/modules/Jabby/ui/components/graph/bargraph.luau @@ -0,0 +1,77 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) +local container = require(script.Parent.Parent.util.container) + +local create = vide.create +local indexes = vide.indexes +local read = vide.read + +type can = T | () -> T +type props = { + + position: can?, + size: can?, + anchorpoint: can?, + + values: () -> {number}, + max: can?, + min: can?, + + [number]: Instance + +} + +return function(props: props) + + local max = props.max or function() + return math.max(unpack(props.values())) + end + + local function total() + return #props.values() + end + + return container { + + Position = props.position, + Size = props.size, + AnchorPoint = props.anchorpoint, + + ClipsDescendants = true, + + indexes(props.values, function(value, index) + + return create "Frame" { + AutoLocalize = false, + + Position = function() + return UDim2.fromScale((index - 1) / total(), 1) + end, + Size = function() + return UDim2.fromScale( + 1/total(), + value() / read(max) + ) + end, + AnchorPoint = Vector2.new(0, 1), + + create "UIGradient" { + Color = function() + return ColorSequence.new( + theme.acc[10](), + theme.acc[-3]() + ) + end, + + Rotation = 90 + } + + } + + end), + + unpack(props) + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/graph/graph.luau b/modules/Jabby/ui/components/graph/graph.luau new file mode 100644 index 0000000..0e2c8d8 --- /dev/null +++ b/modules/Jabby/ui/components/graph/graph.luau @@ -0,0 +1,53 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) +local container = require(script.Parent.Parent.util.container) + +local create = vide.create +local source = vide.source +local action = vide.action +local effect = vide.effect + +type can = T | () -> T +type props = { + + position: can?, + size: can?, + anchorpoint: can?, + + values: () -> {Path2DControlPoint}, + + [number]: Instance + +} + +return function(props: props) + + local path2d: vide.Source = source() + + effect(function() + if not path2d() then return end + + local path = path2d() + path:SetControlPoints(props.values()) + end) + + return container { + + Position = props.position, + Size = props.size, + AnchorPoint = props.anchorpoint, + + create "Path2D" { + + Thickness = 2, + Color3 = theme.acc[3], + + action(path2d) + + }, + + unpack(props) + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/graph/linegraph.luau b/modules/Jabby/ui/components/graph/linegraph.luau new file mode 100644 index 0000000..a2c1cb9 --- /dev/null +++ b/modules/Jabby/ui/components/graph/linegraph.luau @@ -0,0 +1,74 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) +local container = require(script.Parent.Parent.util.container) + +local create = vide.create +local source = vide.source +local action = vide.action +local effect = vide.effect +local read = vide.read + +type can = T | () -> T +type props = { + + position: can?, + size: can?, + anchorpoint: can?, + + values: () -> {number}, + max: can?, + min: can?, + + [number]: Instance +} + +return function(props: props) + + local path2d: vide.Source = source() + + effect(function() + if not path2d() then return end + + local path = path2d() + local points = table.create(50) + local total = #props.values() + local max = read(props.max) or 100 + local min = read(props.min) or 0 + local diff = math.abs(max - min) + + for index, value in props.values() do + table.insert( + points, + Path2DControlPoint.new( + UDim2.fromScale( + (index - 1) / (total - 1), 1 - (value - min) / diff + ) + ) + ) + end + + path:SetControlPoints(points) + end) + + return container { + + Position = props.position, + Size = props.size, + AnchorPoint = props.anchorpoint, + + ClipsDescendants = true, + + create "Path2D" { + + Thickness = 2, + Color3 = theme.acc[3], + + action(path2d) + + }, + + unpack(props) + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/interactable/button.luau b/modules/Jabby/ui/components/interactable/button.luau new file mode 100644 index 0000000..30317f8 --- /dev/null +++ b/modules/Jabby/ui/components/interactable/button.luau @@ -0,0 +1,160 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local anim = require(script.Parent.Parent.Parent.util.anim) +local theme = require(script.Parent.Parent.Parent.util.theme) +local typography = require(script.Parent.Parent.display.typography) +local padding = require(script.Parent.Parent.util.padding) + +local create = vide.create +local source = vide.source +local changed = vide.changed +local show = vide.show +local read = vide.read + +type can = T | () -> T +type props = { + + size: can?, + position: can?, + anchorpoint: can?, + automaticsize: can?, + + text: can?, + disabled: can?, + + activated: () -> ()?, + mouse2: () -> ()?, + down: () -> ()?, + up: () -> ()?, + + --- enables the stroke (enabled by default) + stroke: can?, + --- enables the corner (enabled by default) + corner: can?, + accent: can?, + + xalignment: can?, + + code: can?, + + [number]: any + +} + +return function(props: props) + + local guistate = source(Enum.GuiState.Idle) + + local function bg() + local accent = read(props.accent) + local guistate = guistate() + + return if accent then + if guistate == Enum.GuiState.NonInteractable then theme.acc[-5]() + elseif guistate == Enum.GuiState.Idle then theme.acc[0]() + elseif guistate == Enum.GuiState.Hover then theme.acc[3]() + elseif guistate == Enum.GuiState.Press then theme.acc[-8]() + else theme.acc[0]() + else + if guistate == Enum.GuiState.NonInteractable then theme.bg[-2]() + elseif guistate == Enum.GuiState.Idle then theme.bg[3]() + elseif guistate == Enum.GuiState.Hover then theme.bg[6]() + elseif guistate == Enum.GuiState.Press then theme.bg[0]() + else theme.acc[0]() + end + + local function stroke() + local accent = read(props.accent) + local guistate = guistate() + + return if accent then + if guistate == Enum.GuiState.NonInteractable then theme.acc[-7]() + else theme.acc[-7]() + else + if guistate == Enum.GuiState.NonInteractable then theme.bg[-3]() + else theme.bg[-3]() + end + + return create "TextButton" { + + Name = props.text, + AutoLocalize = false, + + Size = props.size or UDim2.fromOffset(100, 30), + Position = props.position, + AnchorPoint = props.anchorpoint, + AutomaticSize = props.automaticsize, + + Interactable = function() + return not read(props.disabled) + end, + + BackgroundColor3 = anim(bg), + + Activated = props.activated, + MouseButton2Click = props.mouse2, + MouseButton1Down = props.down, + MouseButton1Up = props.up, + + typography { + + position = UDim2.fromScale(0.5, 0.5), + anchorpoint = Vector2.new(0.5, 0.5), + size = UDim2.fromScale(1, 1), + automaticsize = Enum.AutomaticSize.Y, + + text = props.text, + truncate = Enum.TextTruncate.SplitWord, + xalignment = props.xalignment, + + accent = props.accent, + disabled = props.disabled, + + visible = function() + return read(props.text) ~= "" + end, + + code = props.code, + + create "UIFlexItem" { + FlexMode = Enum.UIFlexMode.Fill + } + + }, + + show( + function() + return read(props.stroke) ~= false + end, + source( + create "UIStroke" { + ApplyStrokeMode = Enum.ApplyStrokeMode.Border, + + Color = anim(stroke), + Thickness = 1, + Enabled = props.stroke + } + ) + ), + + show( + function() + return read(props.corner) ~= false + end, + source( + create "UICorner" { + CornerRadius = UDim.new(0,4) + } + ) + ), + + padding { + x = UDim.new(0, 8), + y = UDim.new(0, 2) + }, + + changed("GuiState", guistate), + + unpack(props), + + } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/interactable/select.luau b/modules/Jabby/ui/components/interactable/select.luau new file mode 100644 index 0000000..d973fde --- /dev/null +++ b/modules/Jabby/ui/components/interactable/select.luau @@ -0,0 +1,166 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local theme = require(script.Parent.Parent.Parent.util.theme) +local scroll_frame = require(script.Parent.Parent.display.scroll_frame) +local container = require(script.Parent.Parent.util.container) +local list = require(script.Parent.Parent.util.list) +local padding = require(script.Parent.Parent.util.padding) +local portal = require(script.Parent.Parent.util.portal) +local button = require(script.Parent.button) + +local create = vide.create +local source = vide.source +local changed = vide.changed +local indexes = vide.indexes +local spring = vide.spring +local read = vide.read + +local MAX_SIZE = 100 + +type can = T | () -> T +type dropdown = { + size: can?, + position: can?, + anchorpoint: can?, + + selected: can, + update_selected: (() -> number)?, + + options: can<{string}> +} + +local function dropdown(props: dropdown) + + local selected = props.selected + local update_selected = props.update_selected or function() end + local options = props.options + + local enabled = source(false) + local absolute_size = source(Vector2.zero) + + local size = spring(function() + if not enabled() then return UDim2.fromScale(1, 0) end + return UDim2.new(1, 0, 0, math.min(MAX_SIZE, absolute_size().Y)) + end, 0.1) + + return button { + + size = props.size or UDim2.fromOffset(200, 32), + position = props.position or UDim2.fromScale(0.5, 0.5), + anchorpoint = props.anchorpoint or Vector2.new(0.5, 0.5), + + xalignment = Enum.TextXAlignment.Left, + + text = function() + return read(options)[read(selected)] + end, + + activated = function() + enabled(not enabled()) + end, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8), + }, + + container { + + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.fromScale(1, 0), + Size = UDim2.new(0, 18, 0, 16), + + LayoutOrder = -1, + + create "ImageLabel" { + + Name = "arrow", + AutoLocalize = false, + + Size = UDim2.new(0, 8, 0, 4), + Position = UDim2.fromScale(0.5, 0.5), + AnchorPoint = Vector2.new(0.5, 0.5), + Rotation = spring(function() + return if enabled() then -180 else 0 + end, 0.1), + + BackgroundTransparency = 1, + + BackgroundColor3 = theme.fg_on_bg_high[3], + + Image = "rbxassetid://7260137654", + ImageColor3 = theme.fg_on_bg_low[3], + ScaleType = Enum.ScaleType.Stretch + + } + + }, + + portal { + inherit_layout = true, + + container { + Position = UDim2.new(0, 1, 1, 4), + Size = size, + + BackgroundTransparency = 0, + BackgroundColor3 = theme.bg[3], + + ClipsDescendants = true, + + Visible = function() + return size().Y.Offset > 1 + end, + + padding { + padding = UDim.new(0, 2) + }, + + scroll_frame { + Size = UDim2.fromScale(1, 1), + ScrollBarThickness = 4, + AutomaticCanvasSize = Enum.AutomaticSize.Y, + + list { + spacing = UDim.new(0, 1), + + changed("AbsoluteSize", absolute_size), + + indexes(function() + return read(options) + end, function(value, key) + return button { + size = UDim2.new(1, 0, 0, 30), + text = value, + stroke = false, + + activated = function() + enabled(false) + update_selected(key) + end, + + create "UIListLayout" { + FillDirection = Enum.FillDirection.Horizontal, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 8), + }, + } + end) + } + }, + + create "UIStroke" { + Color = theme.bg[-3] + }, + + create "UICorner" { + CornerRadius = UDim.new(0, 3) + } + } + } + + } + +end + +return dropdown \ No newline at end of file diff --git a/modules/Jabby/ui/components/interactable/textfield.luau b/modules/Jabby/ui/components/interactable/textfield.luau new file mode 100644 index 0000000..a8c42f1 --- /dev/null +++ b/modules/Jabby/ui/components/interactable/textfield.luau @@ -0,0 +1,175 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local anim = require(script.Parent.Parent.Parent.util.anim) +local theme = require(script.Parent.Parent.Parent.util.theme) +local typography = require(script.Parent.Parent.display.typography) +local padding = require(script.Parent.Parent.util.padding) + +local create = vide.create +local source = vide.source +local changed = vide.changed +local effect = vide.effect +local action = vide.action +local read = vide.read + +type can = T | () -> T +type props = { + + size: can?, + position: can?, + anchorpoint: can?, + + text: can?, + placeholder: can?, + + multiline: can?, + code: can?, + + disabled: can?, + + stroke: can?, + corner: can?, + + --- called whenever a character is added / removed + oninput: ((new: string) -> ())?, + --- called whenever focus is lost + focuslost: ((text: string, enter: boolean?) -> ())?, + --- called whenever focus is lost by pressing enter + enter: ((text: string) -> ())?, + +} + +return function(props: props) + + local guistate = source(Enum.GuiState.Idle) + local focused = source(false) + local textbox = source() :: vide.Source + local text = source("") + + effect(function() + text(read(props.text) or "") + end) + + local function bg() + local guistate = guistate() + + return if guistate == Enum.GuiState.NonInteractable then theme.bg[0]() + elseif focused() then theme.bg[-3]() + else theme.bg[-2]() + end + + local function fg() + local disabled = read(props.disabled) + + return if disabled then theme.fg_on_bg_low[0]() + else theme.fg_on_bg_high[0]() + end + + local function stroke() + local guistate = guistate() + + return if guistate == Enum.GuiState.NonInteractable then theme.bg[-3]() + elseif focused() then theme.acc[5]() + elseif guistate == Enum.GuiState.Idle then theme.bg[-3]() + elseif guistate == Enum.GuiState.Hover then theme.bg[3]() + else theme.bg[-3]() + end + + -- this effect will automatically focus the textbox if focused is true + effect(function() + if focused() == true and textbox() then + textbox():CaptureFocus() + end + end) + + return create "TextButton" { + + Name = props.placeholder or "Textbox", + AutoLocalize = false, + + Size = props.size or UDim2.fromOffset(300, 30), + Position = props.position, + AnchorPoint = props.anchorpoint, + + Activated = function() + focused(true) + end, + + Interactable = function() + return not props.disabled + end, + + BackgroundColor3 = anim(bg), + + ClipsDescendants = true, + + create "TextBox" { + + Size = UDim2.fromScale(1, 1), + AutoLocalize = false, + + MultiLine = props.multiline, + + BackgroundTransparency = 1, + + Focused = function() + focused(true) + end, + + FocusLost = function(enter) + focused(false) + if props.focuslost then + props.focuslost(text(), enter) + end + + if props.enter then + props.enter(text()) + end + end, + + TextSize = theme.body, + FontFace = function() + return if read(props.code) then theme.code else theme.font + end, + TextColor3 = anim(fg), + PlaceholderColor3 = theme.fg_on_bg_low[0], + + PlaceholderText = props.placeholder, + Text = props.text, + + ClipsDescendants = true, + + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = function() + return if read(props.multiline) then Enum.TextYAlignment.Top else Enum.TextYAlignment.Center + end, + + action(textbox), + changed("Text", text), + if props.oninput then changed("Text", props.oninput) else nil + + }, + + create "UIStroke" { + ApplyStrokeMode = Enum.ApplyStrokeMode.Border, + + Color = anim(stroke), + Thickness = 1, + Enabled = props.stroke + }, + + create "UICorner" { + CornerRadius = function() + return if read(props.corner) == false then UDim.new() else UDim.new(0, 4) + end + }, + + padding { + x = UDim.new(0, 8), + y = UDim.new(0, 2) + }, + + changed("GuiState", guistate) + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/util/container.luau b/modules/Jabby/ui/components/util/container.luau new file mode 100644 index 0000000..8ada78a --- /dev/null +++ b/modules/Jabby/ui/components/util/container.luau @@ -0,0 +1,25 @@ +--[[ + +container is a basic transparent frame that covers the entire frame. + +]] + +local vide = require(script.Parent.Parent.Parent.Parent.vide) + +local create = vide.create + +local function container(props: vide.vFrame) + return create "Frame" { + + Name = "Container", + AutoLocalize = false, + + Size = UDim2.fromScale(1, 1), + + BackgroundTransparency = 1, + + props, + } +end + +return container diff --git a/modules/Jabby/ui/components/util/gap.luau b/modules/Jabby/ui/components/util/gap.luau new file mode 100644 index 0000000..fac95df --- /dev/null +++ b/modules/Jabby/ui/components/util/gap.luau @@ -0,0 +1,31 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local container = require(script.Parent.container) + +local read = vide.read + +type can = T | () -> T +type props = { + + gap: can, + direction: can<"x" | "y">?, + +} + +return function(props: props) + + local function direction() + return read(props.direction) or "x" + end + + return container { + + Size = function() + return if direction() == "x" then + UDim2.new(0, read(props.gap), 1, 0) + else + UDim2.new(1, 0, 0, read(props.gap)) + end + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/util/list.luau b/modules/Jabby/ui/components/util/list.luau new file mode 100644 index 0000000..caf75d1 --- /dev/null +++ b/modules/Jabby/ui/components/util/list.luau @@ -0,0 +1,55 @@ +--[[ + +Creates a container for a list of elements. + +]] + +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local container = require(script.Parent.container) + +local create = vide.create +local read = vide.read + +type can = (() -> T) | T +type layout = { + + justifycontent: can?, + alignitems: can?, + spacing: can?, + wraps: can?, + + [number]: Instance +} + +local function layout(props: layout) + return container { + + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + + create "UIListLayout" { + Padding = function() + local spacing: number | UDim? = read(props.spacing) + + return if typeof(spacing) == "number" then + UDim.new(0, spacing) + elseif typeof(spacing) == "UDim" then + spacing + elseif typeof(spacing) == "nil" then + UDim.new(0, 8) + else + error("incorrect spacing type") + end, + + VerticalFlex = props.justifycontent, + ItemLineAlignment = props.alignitems, + Wraps = props.wraps + + }, + + unpack(props) + } + +end + +return layout \ No newline at end of file diff --git a/modules/Jabby/ui/components/util/padding.luau b/modules/Jabby/ui/components/util/padding.luau new file mode 100644 index 0000000..32c0132 --- /dev/null +++ b/modules/Jabby/ui/components/util/padding.luau @@ -0,0 +1,41 @@ +--[[ + +Fast and easy to use padding utility to make controlling padding quick and simple. + +]] + +local vide = require(script.Parent.Parent.Parent.Parent.vide) + +local create = vide.create + +type can = T | () -> T +type padding = { + padding: can?, + x: can?, + y: can?, + left: can?, + right: can?, + top: can?, + bottom: can? +} + +local function padding(props: padding) + + local padding = props.padding or UDim.new(0, 8) + local x = props.x or padding + local y = props.y or padding + local left = props.left or x + local right = props.right or x + local top = props.top or y + local bottom = props.bottom or y + + return create "UIPadding" { + PaddingLeft = left, + PaddingRight = right, + PaddingTop = top, + PaddingBottom = bottom, + } + +end + +return padding \ No newline at end of file diff --git a/modules/Jabby/ui/components/util/portal.luau b/modules/Jabby/ui/components/util/portal.luau new file mode 100644 index 0000000..6be0f08 --- /dev/null +++ b/modules/Jabby/ui/components/util/portal.luau @@ -0,0 +1,102 @@ +--[[ + +portal is used to render a component over other components. +it will find the nearest layer collector to parent itself and it's descendants +onto, and if inherit_layout is enabled, inherits the nearest guibase2d's size and +position properties. + +]] +local vide = require(script.Parent.Parent.Parent.Parent.vide) + +local create = vide.create +local source = vide.source +local effect = vide.effect +local cleanup = vide.cleanup +local ref = vide.action +local read = vide.read + +type can = T | () -> T +type portal = { + --- controls if the portal should inherit the layout of the frame it's under + inherit_layout: can?, + + [number]: Instance, +} + +local layout = 100_000 + +local function portal(props: portal) + + local inherit_layout = props.inherit_layout + + local nearest_gui_base = source(nil :: GuiBase2d?) + local nearest_layer_collector = source(nil :: LayerCollector?) + + local size = source(UDim2.fromScale(1, 1)) + local position = source(UDim2.fromScale(0, 0)) + local reference = source(nil :: Configuration?) + + -- this will create connections to update the size and position sources + effect(function() + local object = nearest_gui_base() + if not object then return end + + local function update() + size(UDim2.fromOffset(object.AbsoluteSize.X, object.AbsoluteSize.Y)) + position(UDim2.fromOffset(object.AbsolutePosition.X, object.AbsolutePosition.Y)) + end + + cleanup(object:GetPropertyChangedSignal("AbsoluteSize"):Connect(update)) + cleanup(object:GetPropertyChangedSignal("AbsolutePosition"):Connect(update)) + end) + + -- creates a container that is mounted to somewhere. + cleanup(vide.mount(function() + cleanup(create "Frame" { + + Name = `Portal:{layout}`, + Parent = nearest_layer_collector, + AutoLocalize = false, + ZIndex = layout, + + Size = function() + return if read(inherit_layout) == true then size() + else UDim2.fromScale(1, 1) + end, + Position = function() + return if read(inherit_layout) == true then position() + else UDim2.fromScale(0, 0) + end, + + BackgroundTransparency = 1, + + unpack(props) + + }) + end)) + + -- this is an anchor used to reference what gui base and layer collector to use. + return create "Configuration" { + Name = `PortalAnchor:{layout}`, + + AncestryChanged = function() + local reference = reference() + if not reference then + nearest_gui_base(nil) + return + end + nearest_gui_base(reference:FindFirstAncestorWhichIsA("GuiBase2d")) + nearest_layer_collector(reference:FindFirstAncestorWhichIsA("LayerCollector")) + end, + + ref(function(instance) + layout += 1 + reference(instance) + nearest_gui_base(instance:FindFirstAncestorWhichIsA("GuiBase2d")) + nearest_layer_collector(instance:FindFirstAncestorWhichIsA("LayerCollector")) + end) + } + +end + +return portal \ No newline at end of file diff --git a/modules/Jabby/ui/components/util/rounded_frame.luau b/modules/Jabby/ui/components/util/rounded_frame.luau new file mode 100644 index 0000000..535ba4b --- /dev/null +++ b/modules/Jabby/ui/components/util/rounded_frame.luau @@ -0,0 +1,201 @@ +--[[ + +rounded_frame is a special kind of frame with UICorner controls for every +single corner. + +]] + +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local container = require(script.Parent.container) + +local create = vide.create +local read = vide.read + +type can = T | () -> T +type rounded_frame = { + name: can?, + size: can?, + position: can?, + anchor_point: can?, + + topleft: can?, + topright: can?, + bottomleft: can?, + bottomright: can?, + + color: can?, + + layout: vide.vFrame?, + + [number]: any, +} + +local function rounded_frame(props: rounded_frame) + local topleft = props.topleft or UDim.new() + local topright = props.topright or UDim.new() + local bottomleft = props.bottomleft or UDim.new() + local bottomright = props.bottomright or UDim.new() + + local function corner(name: string, position: UDim2, anchor_point: Vector2, udim: can) + return create "Frame" { + Name = name, + AutoLocalize = false, + + Size = function() + return UDim2.new(read(udim), read(udim)) + end, + Position = position, + AnchorPoint = anchor_point, + + BackgroundTransparency = 1, + ClipsDescendants = true, + + create "Frame" { + Name = "TopLeft", + AutoLocalize = false, + + Size = UDim2.fromScale(2, 2), + Position = UDim2.fromScale(-anchor_point.X, -anchor_point.Y), + + BackgroundColor3 = props.color, + ClipsDescendants = true, + + create "UICorner" { + CornerRadius = udim + } + } + + } + end + + return create "Frame" { + + Name = props.name or "RoundedFrame", + Size = props.size, + Position = props.position, + AnchorPoint = props.anchor_point, + + BackgroundColor3 = props.color, + + BackgroundTransparency = 1, + + create "Folder" { + Name = "Corner", + + corner("TopLeft", UDim2.fromScale(0, 0), Vector2.new(0, 0), topleft), + corner("TopRight", UDim2.fromScale(1, 0), Vector2.new(1, 0), topright), + corner("BottomLeft", UDim2.fromScale(0, 1), Vector2.new(0, 1), bottomleft), + corner("BottomRight", UDim2.fromScale(1, 1), Vector2.new(1, 1), bottomright), + + create "Frame" { + AutoLocalize = false, + + Name = "FrameLeft", + + Size = function() + return UDim2.new( + 0.5, + 0, + 1 - read(topleft).Scale - read(bottomleft).Scale, + - (read(topleft).Offset + read(bottomleft).Offset) + ) + end, + Position = function() + return UDim2.new( + 0, 0, + 0.5 + read(topleft).Scale / 2 - read(bottomleft).Scale / 2, + 0 + read(topleft).Offset / 2 - read(bottomleft).Offset / 2 + ) + end, + AnchorPoint = Vector2.new(0, 0.5), + + BackgroundColor3 = props.color, + }, + + create "Frame" { + + Name = "FrameRight", + AutoLocalize = false, + + Size = function() + return UDim2.new( + 0.5, + 0, + 1 - read(topright).Scale - read(bottomright).Scale, + - (read(topright).Offset + read(bottomright).Offset) + ) + end, + Position = function() + return UDim2.new( + 1, 0, + 0.5 + read(topright).Scale / 2 - read(bottomright).Scale / 2, + 0 + read(topright).Offset / 2 - read(bottomright).Offset / 2 + ) + end, + AnchorPoint = Vector2.new(1, 0.5), + + BackgroundColor3 = props.color, + }, + + create "Frame" { + + Name = "FrameTop", + AutoLocalize = false, + + Size = function() + return UDim2.new( + 1 - read(topleft).Scale - read(topright).Scale, + - (read(topleft).Offset + read(topright).Offset), + 0.5, + 0 + ) + end, + Position = function() + return UDim2.new( + 0.5 + read(topleft).Scale / 2 - read(topright).Scale / 2, + 0 + read(topleft).Offset / 2 - read(topright).Offset / 2, + 0, 0 + ) + end, + AnchorPoint = Vector2.new(0.5, 0), + + BackgroundColor3 = props.color, + }, + + create "Frame" { + + Name = "FrameBottom", + AutoLocalize = false, + + Size = function() + return UDim2.new( + 1 - read(bottomleft).Scale - read(bottomright).Scale, + - (read(bottomleft).Offset + read(bottomright).Offset), + 0.5, + 0 + ) + end, + Position = function() + return UDim2.new( + 0.5 + read(bottomleft).Scale / 2 - read(bottomright).Scale / 2, + 0 + read(bottomleft).Offset / 2 - read(bottomright).Offset / 2, + 1, 0 + ) + end, + AnchorPoint = Vector2.new(0.5, 1), + + BackgroundColor3 = props.color, + }, + }, + + container { + unpack(props) + }, + + props.layout + + } + +end + +return rounded_frame \ No newline at end of file diff --git a/modules/Jabby/ui/components/util/row.luau b/modules/Jabby/ui/components/util/row.luau new file mode 100644 index 0000000..7aba281 --- /dev/null +++ b/modules/Jabby/ui/components/util/row.luau @@ -0,0 +1,56 @@ +--[[ + +Creates a container for a list of elements. + +]] + +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local container = require(script.Parent.container) + +local create = vide.create +local read = vide.read + +type can = (() -> T) | T +type layout = { + + justifycontent: can?, + alignitems: can?, + spacing: can?, + wraps: can?, + + [number]: Instance +} + +local function layout(props: layout) + return container { + + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + + create "UIListLayout" { + Padding = function() + local spacing: number | UDim? = read(props.spacing) + + return if typeof(spacing) == "number" then + UDim.new(0, spacing) + elseif typeof(spacing) == "UDim" then + spacing + elseif typeof(spacing) == "nil" then + UDim.new(0, 8) + else + error("incorrect spacing type") + end, + FillDirection = Enum.FillDirection.Horizontal, + + HorizontalFlex = props.justifycontent, + ItemLineAlignment = props.alignitems, + Wraps = props.wraps + + }, + + unpack(props) + } + +end + +return layout \ No newline at end of file diff --git a/modules/Jabby/ui/components/util/shadow.luau b/modules/Jabby/ui/components/util/shadow.luau new file mode 100644 index 0000000..2621506 --- /dev/null +++ b/modules/Jabby/ui/components/util/shadow.luau @@ -0,0 +1,20 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) + +local create = vide.create + +type can = T | () -> T +type props = { + zindex: can?, + transparency: can? +} + +return function(props: props) + + return create "UIStroke" { + + Thickness = 2, + Color = Color3.new(0, 0, 0), + Transparency = 0.8 + + } +end \ No newline at end of file diff --git a/modules/Jabby/ui/components/util/virtualscroller.luau b/modules/Jabby/ui/components/util/virtualscroller.luau new file mode 100644 index 0000000..9f427ec --- /dev/null +++ b/modules/Jabby/ui/components/util/virtualscroller.luau @@ -0,0 +1,189 @@ +local vide = require(script.Parent.Parent.Parent.Parent.vide) +local scroll_frame = require(script.Parent.Parent.display.scroll_frame) +local container = require(script.Parent.container) + +local create = vide.create +local source = vide.source +local values = vide.values +local changed = vide.changed +local effect = vide.effect +local untrack = vide.untrack +local batch = vide.batch + +type can = T | () -> T +type props = { + + size: can?, + position: can?, + anchorpoint: can?, + + --- streams in items. when index is -1, should expect to be unused + item: (index: () -> number) -> Instance, + --- streams in separators. when index is -1, should expect to be unused + separator: ((index: () -> number) -> Instance)?, + + item_size: number, + separator_size: number?, + + max_items: (() -> number)?, + + [number]: any + +} + +return function(props: props) + + local items = source({} :: {vide.Source}) + + local absolute_size = source(Vector2.zero) + local canvas_position = source(Vector2.zero) + + local item_size = props.item_size + local separator_size = props.separator_size or 0 + + local item = props.item + local separator = props.separator + + local OVERFLOW = 4 + + effect(function() + local absolute_size = absolute_size() + local canvas_position = canvas_position() + + local child_size = item_size + separator_size + local total_required = math.ceil(absolute_size.Y / child_size) + OVERFLOW + local sources = untrack(items) + + local min_index = math.floor(canvas_position.Y / child_size) + local max_index = math.ceil((canvas_position.Y + absolute_size.Y) / child_size) + + local max_items = math.huge + if props.max_items then + max_items = props.max_items() + end + + batch(function() + untrack(function() + -- mark any sources out of range as unused + local unused = {} + + for i, s in sources do + local index = s() + + if + index >= math.max(min_index, 1) + and index <= math.min(max_index, max_items) + then continue end + unused[i] = true + s(-1) + end + + -- add sources necessary + if #sources < total_required then + for i = #sources + 1, total_required do + sources[i] = source(-1) + unused[i] = true + end + items(sources) + end + + -- update indexes of any sources that went unused + local did_not_render = {} + + for i = math.max(min_index, 1), math.min(max_index, max_items) do + did_not_render[i] = true + end + + + for _, s in sources do + did_not_render[s()] = nil + end + + for index in unused do + local s = sources[index] + local key = next(did_not_render) + if not key then break end + s(key) + did_not_render[key] = nil + unused[index] = nil + end + + -- remove unnecessary sources + if #sources > total_required then + for i = #sources, 1, -1 do + if unused[i] then + table.remove(sources, i) + end + unused[i] = nil + if #sources < total_required then break end + end + items(sources) + end + + end) + end) + + end) + + return scroll_frame { + + unpack(props), + + Size = props.size or UDim2.fromScale(1, 1), + Position = props.position, + AnchorPoint = props.anchorpoint, + + BackgroundTransparency = 1, + + CanvasSize = function() + if props.max_items then + return UDim2.fromOffset(0, props.max_items() * (item_size + separator_size)) + else + local absolute_size = absolute_size() + local canvas_position = canvas_position() + local child_size = item_size + separator_size + local max_index = math.ceil((canvas_position.Y + absolute_size.Y) / child_size) + OVERFLOW + return UDim2.fromOffset(0, max_index * child_size) + end + end, + + values(items, function(index) + return create "Frame" { + Name = index, + AutoLocalize = false, + + Position = function() + if index() == -1 then UDim2.fromOffset(0, -1000) end + return UDim2.fromOffset( + 0, + (item_size + separator_size) * (index() - 1) + ) + end, + Size = UDim2.new(1, 0, 0, item_size + separator_size), + + BackgroundTransparency = 1, + + container { + Name = "Item", + + item(index), + }, + + if separator then + container { + Name = "Separator", + + separator(index) + } + else nil, + + } + end), + + changed("AbsoluteSize", absolute_size), + changed("CanvasPosition", canvas_position), + + + } + +end \ No newline at end of file diff --git a/modules/Jabby/ui/init.luau b/modules/Jabby/ui/init.luau new file mode 100644 index 0000000..1f1cd28 --- /dev/null +++ b/modules/Jabby/ui/init.luau @@ -0,0 +1,67 @@ +local accordion = require(script.components.display.accordion) +local background = require(script.components.display.background) +local checkbox = require(script.components.display.checkbox) +local divider = require(script.components.display.divider) +local pages = require(script.components.display.pages) +local pane = require(script.components.display.pane) +local snapping = require(script.components.display.snapping) +local tablesheet = require(script.components.display.tablesheet) +local typography = require(script.components.display.typography) +local widget = require(script.components.display.widget) +local bargraph = require(script.components.graph.bargraph) +local graph = require(script.components.graph.graph) +local linegraph = require(script.components.graph.linegraph) +local button = require(script.components.interactable.button) +local select = require(script.components.interactable.select) +local textfield = require(script.components.interactable.textfield) +local container = require(script.components.util.container) +local gap = require(script.components.util.gap) +local list = require(script.components.util.list) +local padding = require(script.components.util.padding) +local rounded_frame = require(script.components.util.rounded_frame) +local row = require(script.components.util.row) +local shadow = require(script.components.util.shadow) +local virtualscroller = require(script.components.util.virtualscroller) +local anim = require(script.util.anim) +local oklch = require(script.util.oklch) +local theme = require(script.util.theme) + +return { + + widget = widget, + background = background, + pane = pane, + snapping = snapping, + typography = typography, + tablesheet = tablesheet, + accordion = accordion, + divider = divider, + checkbox = checkbox, + + bargraph = bargraph, + graph = graph, + linegraph = linegraph, + + button = button, + textfield = textfield, + select = select, + + pages = pages, + + theme = theme, + anim = anim, + + container = container, + rounded_frame = rounded_frame, + list = list, + row = row, + shadow = shadow, + + padding = padding, + + virtualscroller = virtualscroller, + gap = gap, + + oklch = oklch, + +} \ No newline at end of file diff --git a/modules/Jabby/ui/libraries/apcaw3.luau b/modules/Jabby/ui/libraries/apcaw3.luau new file mode 100644 index 0000000..f5617ae --- /dev/null +++ b/modules/Jabby/ui/libraries/apcaw3.luau @@ -0,0 +1,350 @@ +local SA98G = { + mainTRC = 2.4, + mainTRCencode = 1 / 2.4, + sRco = 0.2126729, + sGco = 0.7151522, + sBco = 0.0721750, + normBG = 0.56, + normTXT = 0.57, + revTXT = 0.62, + revBG = 0.65, + blkThrs = 0.022, + blkClmp = 1.414, + scaleBoW = 1.14, + scaleWoB = 1.14, + loBoWoffset = 0.027, + loWoBoffset = 0.027, + deltaYmin = 0.0005, + loClip = 0.1, + mFactor = 1.94685544331710, + mFactInv = 1 / 1.94685544331710, + mOffsetIn = 0.03873938165714010, + mExpAdj = 0.2833433964208690, + mExp = 0.2833433964208690 / 1.414, + mOffsetOut = 0.3128657958707580, +} + +local function isNaN(n) + return n ~= n +end + +local function reverseAPCA(contrast, knownY, knownType, returnAs) + if contrast == nil then + contrast = 0 + end + if knownY == nil then + knownY = 1.0 + end + if knownType == nil then + knownType = "bg" + end + if returnAs == nil then + returnAs = "hex" + end + if math.abs(contrast) < 9 then + return false + end + local unknownY = knownY + local knownExp + local unknownExp + --/// APCA 0.0.98G - 4g - W3 Compatible Constants //////////////////// + local scale = if contrast > 0 then SA98G.scaleBoW else SA98G.scaleWoB + local offset = if contrast > 0 then SA98G.loBoWoffset else -SA98G.loWoBoffset + contrast = (assert(tonumber(contrast)) * 0.01 + offset) / scale + -- Soft clamps Y if it is near black. + knownY = if (knownY > SA98G.blkThrs) then knownY else knownY + math.pow(SA98G.blkThrs - knownY, SA98G.blkClmp) + -- set the known and unknown exponents + if knownType == "bg" or knownType == "background" then + knownExp = if contrast > 0 then SA98G.normBG else SA98G.revBG + unknownExp = if contrast > 0 then SA98G.normTXT else SA98G.revTXT + unknownY = math.pow(math.pow(knownY, knownExp) - contrast, 1 / unknownExp) + if isNaN(unknownY) then + return false + end + elseif knownType == "txt" or knownType == "text" then + knownExp = if contrast > 0 then SA98G.normTXT else SA98G.revTXT + unknownExp = if contrast > 0 then SA98G.normBG else SA98G.revBG + unknownY = math.pow(contrast + math.pow(knownY, knownExp), 1 / unknownExp) + if isNaN(unknownY) then + return false + end + else + return false + end + --return contrast +'----'+unknownY; + if unknownY > 1.06 or unknownY < 0 then + return false + end + -- if (unknownY < 0) { return false } // return false on underflow + --unknownY = math.max(unknownY,0.0); + -- unclamp + unknownY = if (unknownY > SA98G.blkThrs) then unknownY else (math.pow(((unknownY + SA98G.mOffsetIn) * SA98G.mFactor), SA98G.mExp) * SA98G.mFactInv) - SA98G.mOffsetOut + -- unknownY - 0.22 * math.pow(unknownY*0.5, 1/blkClmp); + unknownY = math.max(math.min(unknownY, 1.0), 0.0) + if returnAs == "color" then + local colorB = math.round(math.pow(unknownY, SA98G.mainTRCencode) * 255) + local retUse = if (knownType == "bg") then "txtColor" else "bgColor" + return { colorB, colorB, colorB, 1, retUse } + elseif returnAs == "Y" or returnAs == "y" then + return math.max(0.0, unknownY) + else + return false + end +end + +local function sRGBtoY(sRGBcolor: Color3) + local r = sRGBcolor.R + local g = sRGBcolor.G + local b = sRGBcolor.B + + local function simpleExp(chan) + return math.pow(chan, SA98G.mainTRC) + end + + return SA98G.sRco * simpleExp(r) + SA98G.sGco * simpleExp(g) + SA98G.sBco * simpleExp(b) +end + +local function displayP3toY(rgb: Color3) + local mainTRC = 2.4 + local sRco, sGco, sBco = 0.2289829594805780, 0.6917492625852380, 0.0792677779341829 + + local function simpleExp(chan) + return math.pow(chan, mainTRC) + end + + return sRco * simpleExp(rgb.R) + sGco * simpleExp(rgb.G) + sBco * simpleExp(rgb.B) +end + +local function adobeRGBtoY(rgb: Color3) + local mainTRC = 2.35 + + local sRco = 0.2973550227113810 + local sGco = 0.6273727497145280 + local sBco = 0.0752722275740913 + + local function simpleExp(chan) + return math.pow(chan / 255.0, mainTRC) + end + + return sRco * simpleExp(rgb.R) + sGco * simpleExp(rgb.G) + sBco * simpleExp(rgb.B) +end + + +local function APCAcontrast(txtY, bgY, places) + places = places or -1 + + local icp = { 0, 1.1 } + + if math.min(txtY, bgY) < icp[1] or math.max(txtY, bgY) > icp[2] then + return 0 + end + + local SAPC = 0 + local outputContrast = 0 + local polCat = "BoW" + + txtY = (txtY > SA98G.blkThrs) and txtY or txtY + math.pow(SA98G.blkThrs - txtY, SA98G.blkClmp) + bgY = (bgY > SA98G.blkThrs) and bgY or bgY + math.pow(SA98G.blkThrs - bgY, SA98G.blkClmp) + + if math.abs(bgY - txtY) < SA98G.deltaYmin then + return 0 + end + + if bgY > txtY then -- black text on white + SAPC = (math.pow(bgY, SA98G.normBG) - math.pow(txtY, SA98G.normTXT)) * SA98G.scaleBoW + + outputContrast = (SAPC < SA98G.loClip) and 0.0 or SAPC - SA98G.loBoWoffset + else + -- should always return negative + polCat = "WoB" -- white on black + + SAPC = (math.pow(bgY, SA98G.revBG) - math.pow(txtY, SA98G.revTXT)) * SA98G.scaleWoB + + outputContrast = (SAPC > -SA98G.loClip) and 0.0 or SAPC + SA98G.loWoBoffset + end + + if places < 0 then + return outputContrast * 100.0 + elseif places == 0 then + return math.round(math.abs(outputContrast) * 100.0) --+ "" + polCat + "" -- why is there html + elseif places // 1 == places then + return (outputContrast * 100.0) * places // 1 / places + else + return 0.0 + end +end + +local function alphaBlend(rgbFG: Color3, aFG: number, rgbBG: Color3, round: boolean?) + round = if round == nil then true else round + aFG = aFG or 1 + local compBlend = 1 - aFG + local rgbOut = {0, 0, 0} + + rgbOut[1] = rgbBG.R * compBlend + rgbFG.R * aFG + if round then rgbOut[1] = math.min(math.round(rgbOut[1]), 255) end + rgbOut[2] = rgbBG.G * compBlend + rgbFG.G * aFG + if round then rgbOut[2] = math.min(math.round(rgbOut[2]), 255) end + rgbOut[3] = rgbBG.B * compBlend + rgbFG.B * aFG + if round then rgbOut[3] = math.min(math.round(rgbOut[3]), 255) end + + return Color3.new(rgbOut[1], rgbOut[2], rgbOut[3]) + +end + +local function calcAPCA(textcolor: Color3, bgColor: Color3, textalpha: number?, places: number?, round: boolean?) + places = -1 + + --todo: alpha blending + if textalpha then textcolor = alphaBlend(textcolor, textalpha, bgColor, round) end + + return APCAcontrast(sRGBtoY(textcolor), sRGBtoY(bgColor), places) +end + +local function fontLookupAPCA(contrast, places: number?) + places = places or 2 + + -- Font size interpolations. Here the chart was re-ordered to put + -- the main contrast levels each on one line, instead of font size per line. + -- First column is LC value, then each following column is font size by weight + + -- G G G G G G Public Beta 0.1.7 (G) • MAY 28 2022 + + -- Lc values under 70 should have Lc 15 ADDED if used for body text + -- All font sizes are in px and reference font is Barlow + + -- 999: prohibited - too low contrast + -- 777: NON TEXT at this minimum weight stroke + -- 666 - this is for spot text, not fluent-Things like copyright or placeholder. + -- 5xx - minimum font at this weight for content, 5xx % 500 for font-size + -- 4xx - minimum font at this weight for any purpose], 4xx % 400 for font-size + + -- MAIN FONT SIZE LOOKUP + + ---- ASCENDING SORTED Public Beta 0.1.7 (G) • MAY 28 2022 //// + + ---- Lc 45 * 0.2 = 9 which is the index for the row for Lc 45 + + -- MAIN FONT LOOKUP May 28 2022 EXPANDED + -- Sorted by Lc Value + -- First row is standard weights 100-900 + -- First column is font size in px + -- All other values are the Lc contrast + -- 999 = too low. 777 = non-text and spot text only + + local fontMatrixAscend = { + {'Lc',100,200,300,400,500,600,700,800,900}, + {0,999,999,999,999,999,999,999,999,999}, + {10,999,999,999,999,999,999,999,999,999}, + {15,777,777,777,777,777,777,777,777,777}, + {20,777,777,777,777,777,777,777,777,777}, + {25,777,777,777,120,120,108,96,96,96}, + {30,777,777,120,108,108,96,72,72,72}, + {35,777,120,108,96,72,60,48,48,48}, + {40,120,108,96,60,48,42,32,32,32}, + {45,108,96,72,42,32,28,24,24,24}, + {50,96,72,60,32,28,24,21,21,21}, + {55,80,60,48,28,24,21,18,18,18}, + {60,72,48,42,24,21,18,16,16,18}, + {65,68,46,32,21.75,19,17,15,16,18}, + {70,64,44,28,19.5,18,16,14.5,16,18}, + {75,60,42,24,18,16,15,14,16,18}, + {80,56,38.25,23,17.25,15.81,14.81,14,16,18}, + {85,52,34.5,22,16.5,15.625,14.625,14,16,18}, + {90,48,32,21,16,15.5,14.5,14,16,18}, + {95,45,28,19.5,15.5,15,14,13.5,16,18}, + {100,42,26.5,18.5,15,14.5,13.5,13,16,18}, + {105,39,25,18,14.5,14,13,12,16,18}, + {110,36,24,18,14,13,12,11,16,18}, + {115,34.5,22.5,17.25,12.5,11.875,11.25,10.625,14.5,16.5}, + {120,33,21,16.5,11,10.75,10.5,10.25,13,15}, + {125,32,20,16,10,10,10,10,12,14}, + } + + local fontDeltaAscend = { + {'∆Lc',100,200,300,400,500,600,700,800,900}, + {0,0,0,0,0,0,0,0,0,0}, + {10,0,0,0,0,0,0,0,0,0}, + {15,0,0,0,0,0,0,0,0,0}, + {20,0,0,0,0,0,0,0,0,0}, + {25,0,0,0,12,12,12,24,24,24}, + {30,0,0,12,12,36,36,24,24,24}, + {35,0,12,12,36,24,18,16,16,16}, + {40,12,12,24,18,16,14,8,8,8}, + {45,12,24,12,10,4,4,3,3,3}, + {50,16,12,12,4,4,3,3,3,3}, + {55,8,12,6,4,3,3,2,2,0}, + {60,4,2,10,2.25,2,1,1,0,0}, + {65,4,2,4,2.25,1,1,0.5,0,0}, + {70,4,2,4,1.5,2,1,0.5,0,0}, + {75,4,3.75,1,0.75,0.188,0.188,0,0,0}, + {80,4,3.75,1,0.75,0.188,0.188,0,0,0}, + {85,4,2.5,1,0.5,0.125,0.125,0,0,0}, + {90,3,4,1.5,0.5,0.5,0.5,0.5,0,0}, + {95,3,1.5,1,0.5,0.5,0.5,0.5,0,0}, + {100,3,1.5,0.5,0.5,0.5,0.5,1,0,0}, + {105,3,1,0,0.5,1,1,1,0,0}, + {110,1.5,1.5,0.75,1.5,1.125,0.75,0.375,1.5,1.5}, + {115,1.5,1.5,0.75,1.5,1.125,0.75,0.375,1.5,1.5}, + {120,1,1,0.5,1,0.75,0.5,0.25,1,1}, + {125,0,0,0,0,0,0,0,0,0}, + }; + + local weightArray = {0, 100, 200, 300, 400, 500, 600, 700, 800, 900} + local weightArrayLen = #weightArray + + local returnArray = {tostring(contrast * places // 1 / places), 0, 0, 0, 0, 0, 0, 0, 0, 0} + local returnArrayLen = #returnArray + + local contrastArrayAscend = {'lc',0,10,15,20,25,30,35,40,45,50,55,60,65,70,75,80,85,90,95,100,105,110,115,120,125,} + local contrastArrayLenAsc = #contrastArrayAscend + + -- Lc 45 * 0.2 = 9 and 9 is the index for the row for lc 45 + + local tempFont = 777 + local contrast = math.abs(contrast) + local factor = 0.2 + local index = contrast == 0 and 1 or bit32.bor(contrast * factor, 0) + local w = 0 + local scoreAdj = (contrast - fontMatrixAscend[index + 1][w + 1]) * factor + + w += 1 + + while w < weightArrayLen do + w += 1 + tempFont = fontMatrixAscend[index+1][w+1] + + if tempFont > 400 then + returnArray[w + 1] = tempFont + elseif contrast < 14.5 then + returnArray[w + 1] = 999 + elseif contrast < 29.5 then + returnArray[w + 1] = 777 + else + --- interpolation of font size + + if tempFont > 24 then + returnArray[w + 1] = math.round(tempFont - (fontDeltaAscend[index + 1][w + 1] * scoreAdj)) + else + returnArray[w + 1] = tempFont - ((2 * fontDeltaAscend[index + 1][w + 1] * scoreAdj // 1) * 0.5) + + end + end + end + + return returnArray + +end + +return { + + APCAcontrast = APCAcontrast, + reverseAPCA = reverseAPCA, + calcAPCA = calcAPCA, + fontLookupAPCA = fontLookupAPCA, + + sRGBtoY = sRGBtoY, + displayP3toY = displayP3toY, + adobeRGBtoY = adobeRGBtoY, + alphaBlend = alphaBlend + +} \ No newline at end of file diff --git a/modules/Jabby/ui/libraries/cascade.luau b/modules/Jabby/ui/libraries/cascade.luau new file mode 100644 index 0000000..4c17f29 --- /dev/null +++ b/modules/Jabby/ui/libraries/cascade.luau @@ -0,0 +1,62 @@ +local vide = require(script.Parent.Parent.Parent.vide) + +local action = vide.action +local cleanup = vide.cleanup + +--[[ + +Cascades are applied and passed through an instance. +Use a context if you need to pass this through function scopes. + +This is primarily useful for passing down theme information. + +]] +local function cascade(default_value: T) + + local self = {} + local senders: {[Instance]: T} = {} + + local function get_cascaded_value(from: Instance?): T + while from ~= nil do + local sender = senders[from] + if sender ~= nil then + return sender + else + from = from.Parent + end + end + return default_value + end + + function self.send(value: T) + return action(function(instance) + cleanup(function() + senders[instance] = nil + end) + + senders[instance] = value + end) + end + + function self.receive(output_to: (T) -> any) + return action(function(instance) + local function recalculate() + output_to( + get_cascaded_value(instance.Parent) + ) + end + + recalculate() + + cleanup(instance.AncestryChanged:Connect(recalculate)) + cleanup(function() + output_to(default_value) + end) + end) + end + + return self + +end + +return cascade \ No newline at end of file diff --git a/modules/Jabby/ui/libraries/context.luau b/modules/Jabby/ui/libraries/context.luau new file mode 100644 index 0000000..04ec471 --- /dev/null +++ b/modules/Jabby/ui/libraries/context.luau @@ -0,0 +1,57 @@ +--[[ + +Implements a form of dependency injection to save the need from passing data +as props through intermediate components. + +]] + +export type Context = { + default_value: T, + _values: {[thread]: T}, + + provide: (Context, callback: () -> U) -> (new: T) -> U, + consume: (Context) -> T +} + +type ContextNoDefault = { + _values: {[thread]: T}, + + provide: (Context, callback: () -> U) -> (new: T) -> U, + consume: (Context) -> T? +} + +local function provide(context: Context, callback: () -> U) + return function(new: T): U + local thread = coroutine.running() + local old = context._values[thread] + + context._values[thread] = new + + local ok, value = pcall(callback) + + context._values[thread] = old + + if not ok then + error(`provided callback errored with "{value}"`, 2) + end + + return value + end :: (new: T) -> U +end + +local function consume(context: Context): T + local thread = coroutine.running() + return context._values[thread] or context.default_value +end + +local function create_context(default_value: T?): Context + return { + default_value = default_value, + _values = {}, + + provide = provide :: any, + consume = consume + } +end + +return create_context :: ((default_value: T) -> Context) & (() -> ContextNoDefault) \ No newline at end of file diff --git a/modules/Jabby/ui/libraries/delay.luau b/modules/Jabby/ui/libraries/delay.luau new file mode 100644 index 0000000..a7f48ca --- /dev/null +++ b/modules/Jabby/ui/libraries/delay.luau @@ -0,0 +1,26 @@ +local RunService = game:GetService("RunService") + +local vide = require(script.Parent.Parent.Parent.vide) + +local source = vide.source +local effect = vide.effect +local cleanup = vide.cleanup + +return function(delay: number, input: () -> T): () -> T + local output = source(input()) + + effect(function() + local v = input() + local t = delay + + cleanup(RunService.Heartbeat:Connect(function(dt) + t -= dt + if t > 0 then return end + output(v) + + end)) + + end) + + return output +end \ No newline at end of file diff --git a/modules/Jabby/ui/libraries/oklab.luau b/modules/Jabby/ui/libraries/oklab.luau new file mode 100644 index 0000000..1b268cb --- /dev/null +++ b/modules/Jabby/ui/libraries/oklab.luau @@ -0,0 +1,434 @@ +--!nolint +--!strict +-- Oklab C implementation provided by Björn Ottosson: +-- https://bottosson.github.io/posts/gamutclipping/ +-- Luau port and Roblox/Lch extensions by Elttob: +-- https://elttob.uk/ +-- Licensed under MIT + +local TAU = 2 * math.pi + +local function cbrt(x: number) + return math.sign(x) * math.abs(x) ^ (1/3) +end + +local Oklab = {} + +function Oklab.linear_srgb_to_oklab( + c: Vector3 +): Vector3 + local l = 0.4122214708 * c.X + 0.5363325363 * c.Y + 0.0514459929 * c.Z + local m = 0.2119034982 * c.X + 0.6806995451 * c.Y + 0.1073969566 * c.Z + local s = 0.0883024619 * c.X + 0.2817188376 * c.Y + 0.6299787005 * c.Z + + local l_ = cbrt(l) + local m_ = cbrt(m) + local s_ = cbrt(s) + + return Vector3.new( + 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, + 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, + 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + ) +end + +function Oklab.oklab_to_linear_srgb( + c: Vector3 +): Vector3 + local l_ = c.X + 0.3963377774 * c.Y + 0.2158037573 * c.Z + local m_ = c.X - 0.1055613458 * c.Y - 0.0638541728 * c.Z + local s_ = c.X - 0.0894841775 * c.Y - 1.2914855480 * c.Z + + local l = l_ * l_ * l_ + local m = m_ * m_ * m_ + local s = s_ * s_ * s_ + + return Vector3.new( + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s + ) +end + +-- Finds the maximum saturation possible for a given hue that fits in sRGB. +-- Saturation here is defined as S = C/L +-- a and b must be normalised so a^2 + b^2 == 1 +function Oklab.compute_max_saturation( + a: number, + b: number +): number + -- Max saturation will be when one of r, g or b goes below zero. + + -- Select different coefficients depending on which component goes below zero first + local k0, k1, k2, k3, k4, wl, wm, ws + + if -1.88170328 * a - 0.80936493 * b > 1 then + -- Red component + k0, k1, k2, k3, k4 = 1.19086277, 1.76576728, 0.59662641, 0.75515197, 0.56771245 + wl, wm, ws = 4.0767416621, -3.3077115913, 0.2309699292 + elseif 1.81444104 * a - 1.19445276 * b > 1 then + -- Green component + k0, k1, k2, k3, k4 = 0.73956515, -0.45954404, 0.08285427, 0.12541070, 0.14503204 + wl, wm, ws = -1.2684380046, 2.6097574011, -0.3413193965 + else + -- Blue component + k0, k1, k2, k3, k4 = 1.35733652, -0.00915799, -1.15130210, -0.50559606, 0.00692167 + wl, wm, ws = -0.0041960863, -0.7034186147, 1.7076147010 + end + + -- Approximate max saturation using a polynomial + local S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b + + -- Do one step Halley's method to get closer + -- this gives an error less than 10e6, except for some blue hues where the dS/dh is close to infinite + -- this should be sufficient for most applications, otherwise do two/three steps + local k_l = 0.3963377774 * a + 0.2158037573 * b + local k_m = -0.1055613458 * a - 0.0638541728 * b + local k_s = -0.0894841775 * a - 1.2914855480 * b + + do + local l_ = 1 + S * k_l + local m_ = 1 + S * k_m + local s_ = 1 + S * k_s + + local l = l_ * l_ * l_ + local m = m_ * m_ * m_ + local s = s_ * s_ * s_ + + local l_dS = 3 * k_l * l_ * l_ + local m_dS = 3 * k_m * m_ * m_ + local s_dS = 3 * k_s * s_ * s_ + + local l_dS2 = 6 * k_l * k_l * l_ + local m_dS2 = 6 * k_m * k_m * m_ + local s_dS2 = 6 * k_s * k_s * s_ + + local f = wl * l + wm * m + ws * s + local f1 = wl * l_dS + wm * m_dS + ws * s_dS + local f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2 + + S = S - f * f1 / (f1*f1 - 0.5 * f * f2) + end + + return S +end + +-- finds L_cusp and C_cusp for a given hue +-- a and b must be normalised so a^2 + b^2 == 1 +function Oklab.find_cusp( + a: number, + b: number +): (number, number) + -- First, find the maximum saturation (saturation S = C/L) + local S_cusp = Oklab.compute_max_saturation(a, b) + + -- Convert to linear sRGB to find the first point where at least one of r,g or b >= 1: + local rgb_at_max = Oklab.oklab_to_linear_srgb(Vector3.new(1, S_cusp * a, S_cusp * b)) + local L_cusp = cbrt(1 / math.max(rgb_at_max.X, rgb_at_max.Y, rgb_at_max.Z)) + local C_cusp = L_cusp * S_cusp + + return L_cusp, C_cusp +end + +-- Finds intersection of the line defined by +-- L = L0 * (1 - t) + t * L1; +-- C = t * C1; +-- a and b must be normalized so a^2 + b^2 == 1 +function Oklab.find_gamut_intersection( + a: number, + b: number, + L1: number, + C1: number, + L0: number +): number + -- Find the cusp of the gamut triangle + local L_cusp, C_cusp = Oklab.find_cusp(a, b) + + -- Find the intersection for upper and lower half seprately + local t + if ((L1 - L0) * C_cusp - (L_cusp - L0) * C1) <= 0 then + -- Lower half + t = C_cusp * L0 / (C1 * L_cusp + C_cusp * (L0 - L1)) + else + -- Upper half + -- First intersect with triangle + t = C_cusp * (L0 - 1) / (C1 * (L_cusp - 1) + C_cusp * (L0 - L1)) + + -- Then one step Halley's method + do + local dL = L1 - L0 + local dC = C1 + + local k_l = 0.3963377774 * a + 0.2158037573 * b + local k_m = -0.1055613458 * a - 0.0638541728 * b + local k_s = -0.0894841775 * a - 1.2914855480 * b + + local l_dt = dL + dC * k_l + local m_dt = dL + dC * k_m + local s_dt = dL + dC * k_s + + -- If higher accuracy is required, 2 or 3 iterations of the following block can be used: + do + local L = L0 * (1 - t) + t * L1 + local C = t * C1 + + local l_ = L + C * k_l + local m_ = L + C * k_m + local s_ = L + C * k_s + + local l = l_ * l_ * l_ + local m = m_ * m_ * m_ + local s = s_ * s_ * s_ + + local ldt = 3 * l_dt * l_ * l_ + local mdt = 3 * m_dt * m_ * m_ + local sdt = 3 * s_dt * s_ * s_ + + local ldt2 = 6 * l_dt * l_dt * l_ + local mdt2 = 6 * m_dt * m_dt * m_ + local sdt2 = 6 * s_dt * s_dt * s_ + + local r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1 + local r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt + local r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2 + + local u_r = r1 / (r1 * r1 - 0.5 * r * r2) + local t_r = -r * u_r + + local g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1 + local g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt + local g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2 + + local u_g = g1 / (g1 * g1 - 0.5 * g * g2) + local t_g = -g * u_g + + local b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s - 1 + local b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt + local b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2 + + local u_b = b1 / (b1 * b1 - 0.5 * b * b2) + local t_b = -b * u_b + + t_r = if u_r >= 0 then t_r else math.huge + t_g = if u_g >= 0 then t_g else math.huge + t_b = if u_b >= 0 then t_b else math.huge + + t += math.min(t_r, t_g, t_b) + end + end + end + + return t +end + +function Oklab.gamut_clip_preserve_chroma( + rgb: Vector3 +): Vector3 + if rgb.X <= 1 and rgb.Y <= 1 and rgb.Z <= 1 and rgb.X >= 0 and rgb.Y >= 0 and rgb.Z >= 0 then + return rgb + end + + local lab = Oklab.linear_srgb_to_oklab(rgb) + + local L = lab.X + local eps = 0.00001 + local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) + local a_ = if C == 0 then 0 else lab.Y / C + local b_ = if C == 0 then 0 else lab.Z / C + local L0 = math.clamp(L, 0, 1) + + local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) + local L_clipped = L0 * (1 - t) + t * L + local C_clipped = t * C + + return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) +end + +function Oklab.gamut_clip_project_to_0_5( + rgb: Vector3 +): Vector3 + if rgb.X <= 1 and rgb.Y <= 1 and rgb.Z <= 1 and rgb.X >= 0 and rgb.Y >= 0 and rgb.Z >= 0 then + return rgb + end + + local lab = Oklab.linear_srgb_to_oklab(rgb) + + local L = lab.X + local eps = 0.00001 + local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) + local a_ = lab.Y / C + local b_ = lab.Z / C + + local L0 = 0.5 + + local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) + local L_clipped = L0 * (1 - t) + t * L + local C_clipped = t * C + + return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) +end + +function Oklab.gamut_clip_project_to_L_cusp( + rgb: Vector3 +): Vector3 + if rgb.X <= 1 and rgb.Y <= 1 and rgb.Z <= 1 and rgb.X >= 0 and rgb.Y >= 0 and rgb.Z >= 0 then + return rgb + end + + local lab = Oklab.linear_srgb_to_oklab(rgb) + + local L = lab.X + local eps = 0.00001 + local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) + local a_ = lab.Y / C + local b_ = lab.Z / C + + -- The cusp is computed here and in find_gamut_intersection, an optimised solution would only compute it once. + local L_cusp, C_cusp = Oklab.find_cusp(a_, b_) + + local L0 = L_cusp + + local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) + + local L_clipped = L0 * (1 - t) + t * L + local C_clipped = t * C + + return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) +end + +function Oklab.gamut_clip_adaptive_L0_0_5( + rgb: Vector3, + alpha: number? +): Vector3 + if rgb.X <= 1 and rgb.Y <= 1 and rgb.Z <= 1 and rgb.X >= 0 and rgb.Y >= 0 and rgb.Z >= 0 then + return rgb + end + local alpha = alpha or 0.05 + + local lab = Oklab.linear_srgb_to_oklab(rgb) + + local L = lab.X + local eps = 0.00001 + local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) + local a_ = lab.Y / C + local b_ = lab.Z / C + + local Ld = L - 0.5 + local e1 = 0.5 + math.abs(Ld) + alpha * C + local L0 = 0.5 * (1 + math.sign(Ld) * (e1 - math.sqrt(e1*e1 - 2 * math.abs(Ld)))) + + local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) + local L_clipped = L0 * (1 - t) + t * L + local C_clipped = t * C + + return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) +end + +function Oklab.gamut_clip_adaptive_L0_L_cusp( + rgb: Vector3, + alpha: number? +): Vector3 + if rgb.X < 1 and rgb.Y < 1 and rgb.Z < 1 and rgb.X > 0 and rgb.Y > 0 and rgb.Z > 0 then + return rgb + end + local alpha = alpha or 0.05 + + local lab = Oklab.linear_srgb_to_oklab(rgb) + + local L = lab.X + local eps = 0.00001 + local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) + local a_ = lab.Y / C + local b_ = lab.Z / C + + -- The cusp is computed here and in find_gamut_intersection, an optimized solution would only compute it once. + local L_cusp, C_cusp = Oklab.find_cusp(a_, b_) + + local Ld = L - L_cusp + local k = 2 * (if Ld > 0 then 1 - L_cusp else L_cusp) + + local e1 = 0.5*k + math.abs(Ld) + alpha * C/k + local L0 = L_cusp + 0.5 * (math.sign(Ld) * (e1 - math.sqrt(e1 * e1 - 2 * k * math.abs(Ld)))) + + local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) + local L_clipped = L0 * (1 - t) + t * L + local C_clipped = t * C + + return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) +end + +--[[ + ROBLOX EXTENSIONS +]] + +Oklab.default_gamut_clip = Oklab.gamut_clip_adaptive_L0_0_5 + +local function component_to_gamma(x: number): number + if x >= 0.0031308 then + return (1.055) * x^(1.0/2.4) - 0.055 + else + return 12.92 * x + end +end + +local function component_to_linear(x: number): number + if x >= 0.04045 then + return ((x + 0.055)/(1 + 0.055))^2.4 + else + return x / 12.92 + end +end + +function Oklab.color3_to_linear_srgb( + c: Color3 +): Vector3 + return Vector3.new( + component_to_linear(c.R), + component_to_linear(c.G), + component_to_linear(c.B) + ) +end + +function Oklab.linear_srgb_to_color3( + c: Vector3, + use_default_gamut_clip: boolean? +): Color3 + if use_default_gamut_clip == false then + return Color3.new( + component_to_gamma(c.X), + component_to_gamma(c.Y), + component_to_gamma(c.Z) + ) + else + local c = Oklab.default_gamut_clip(c) + return Color3.new( + math.clamp(component_to_gamma(c.X), 0, 1), + math.clamp(component_to_gamma(c.Y), 0, 1), + math.clamp(component_to_gamma(c.Z), 0, 1) + ) + end + + +end + +--[[ + LCH EXTENSIONS +]] + +function Oklab.oklch_to_oklab(oklch: Vector3): Vector3 + return Vector3.new( + oklch.X, + oklch.Y * math.cos(oklch.Z * TAU), + oklch.Y * math.sin(oklch.Z * TAU) + ) +end + +function Oklab.oklab_to_oklch(oklab: Vector3): Vector3 + return Vector3.new( + oklab.X, + math.sqrt(oklab.Y^2 + oklab.Z^2), + (math.atan2(oklab.Z, oklab.Y) / TAU) % 1 + ) +end + +return Oklab \ No newline at end of file diff --git a/modules/Jabby/ui/libraries/store.luau b/modules/Jabby/ui/libraries/store.luau new file mode 100644 index 0000000..3ba305f --- /dev/null +++ b/modules/Jabby/ui/libraries/store.luau @@ -0,0 +1,101 @@ +-------------------------------------------------------------------------------- +-- videx/store.luau +-------------------------------------------------------------------------------- + +local vide = require(script.Parent.Parent.Parent.vide) +local source = vide.source + +local NULL = newproxy() + +local Store = {} + +--[=[ +Creates a new store object that receives some initial state and then returns +a table with the same structure, but all keys of the given table will be reactive. + +When accessed inside a reactive scope, the reactive scope will update whenever +the key that is accessed is changed. + +@param initial_state `T : {[string]: any}` The initial state the store will start in. +@param mutations `() -> {[string]: (T, ...any) -> ...any}?` A list of functions that mutate the data. +@return `T & U` A resulting table that +]=] +function Store.new( + initial_state: T & {}, + mutations: (T & U) -> U +): T & U + local sources = {} + + for i, v in initial_state :: any do + local src = source(v ~= NULL and v or nil) + sources[i] = src + end + + local internal_proxy = {} + + setmetatable(internal_proxy, { + __index = function(_, index) + return sources[index]() + end, + __newindex = function(_, index, value) + sources[index](value) + end + }) + + local external_proxy = {} + + setmetatable(external_proxy :: any, { + __index = function(_, index) + local src = sources[index] + if src == nil then error(`invalid index {index}`, 2) end + return src() + end, + + __newindex = function(_, index, value) + sources[index](value) + end + }) + + for i, v in next, mutations(internal_proxy :: any) :: any do + if rawget(external_proxy, i) then + error(`duplicate field "{i}"`, 2) + end + rawset(external_proxy, i, v) + end + + return external_proxy :: T & U & {} +end + + +--[=[ +Creates a new store object that receives some initial state and then returns +a table with the same structure, but all keys of the given table will be reactive. + +When accessed inside a reactive scope, the reactive scope will update whenever +the key that is accessed is changed. + +@param initial_state `T : {[string]: any}` The initial state the store will start in. +@param mutations `() -> {[string]: (T, ...any) -> ...any}?` A list of functions that mutate the data. +@return `T & U` A resulting table that +]=] +function Store.new_deep( + initial_state: T & {}, + mutations: (T & U) -> U +): T & U + + local main = Store.new(initial_state, mutations) + + for key, value in initial_state :: any do + if type(value) == "table" then + main[key] = Store.new_deep(value, mutations :: any) + end + end + + return main + +end + +--- A special symbol used to indicate that a value should be nil within a Store. +Store.null = NULL :: nil + +return Store \ No newline at end of file diff --git a/modules/Jabby/ui/util/anim.luau b/modules/Jabby/ui/util/anim.luau new file mode 100644 index 0000000..b0b7fe6 --- /dev/null +++ b/modules/Jabby/ui/util/anim.luau @@ -0,0 +1,27 @@ +local vide = require(script.Parent.Parent.Parent.vide) +local reduced_motion = require(script.Parent.reduced_motion) + +local source = vide.source +local spring = vide.spring +local untrack = vide.untrack + +type Animation = "move" | "" + +return function(s: () -> T) + local type = typeof(untrack(s)) + local is_movement = false + local reduce = reduced_motion:consume() + + if type == "UDim" or type == "UDim2" or type == "Vector2" then + is_movement = true + end + + local spr = spring(s, 0.1) + return function() + if is_movement and reduce then + return s() + else + return spr() + end + end +end \ No newline at end of file diff --git a/modules/Jabby/ui/util/constants.luau b/modules/Jabby/ui/util/constants.luau new file mode 100644 index 0000000..e69de29 diff --git a/modules/Jabby/ui/util/consume.luau b/modules/Jabby/ui/util/consume.luau new file mode 100644 index 0000000..ccdae97 --- /dev/null +++ b/modules/Jabby/ui/util/consume.luau @@ -0,0 +1,10 @@ +--[[ + +Consumes a property from a table and removes it + +]] + +return function(t: {}, key: string) + local v = t[key]; t[key] = nil + return v +end \ No newline at end of file diff --git a/modules/Jabby/ui/util/contrast.luau b/modules/Jabby/ui/util/contrast.luau new file mode 100644 index 0000000..22f0793 --- /dev/null +++ b/modules/Jabby/ui/util/contrast.luau @@ -0,0 +1,155 @@ +local vide = require(script.Parent.Parent.Parent.vide) +local apcaw3 = require(script.Parent.Parent.libraries.apcaw3) +local oklch = require(script.Parent.oklch) + +local derive = vide.derive +local read = vide.read + +local function min_contrast(options: { + size: can, + weight: can?, + body: can? +}) + + local size = options.size + local weight = options.weight or 400 + local needs_body = options.body + local NO = math.huge + + if not size and not weight then return 0 end + + local MIN_CONTRAST = { + [12] = {NO, NO, NO, NO, NO, NO, NO, NO, NO}, + [14] = {NO, NO, NO, 100, 100, 90, 75, NO, NO}, + [15] = {NO, NO, NO, 100, 90, 75, 70, NO, NO}, + [16] = {NO, NO, NO, 90, 75, 70, 60, 60, NO}, + [18] = {NO, NO, 100, 75, 70, 60, 55, 55, 55}, + [21] = {NO, NO, 90, 70, 60, 55, 50, 50, 50}, + [24] = {NO, NO, 75, 60, 55, 50, 45, 45, 55}, + [28] = {NO, 100, 70, 55, 50, 45, 43, 43, 43}, + [32] = {NO, 90, 65, 50, 45, 43, 40, 40, 40}, + [36] = {NO, 75, 60, 45, 43, 40, 38, 38, 38}, + [42] = {100, 70, 55, 43, 40, 38, 35, 35, 35}, + [48] = {90, 60, 50, 40, 38, 35, 33, 33, 33}, + [60] = {75, 55, 45, 37, 35, 33, 30 ,30, 30}, + [72] = {60, 50, 40, 35, 33, 30, 30, 30, 30}, + [96] = {50, 45, 35, 33, 30, 30, 30, 30, 30} + } + local MIN_CONTRAST_BODY = { + [12] = {NO, NO, NO, NO, NO, NO, NO, NO, NO}, + [14] = {NO, NO, NO, 100, 100, 90, 75, NO, NO}, + [15] = {NO, NO, NO, 100, 90, 90, 85, NO, NO}, + [16] = {NO, NO, NO, 90, 75, 85, 75, NO, NO}, + [18] = {NO, NO, 100, 75, 85, 75, 70, NO, NO}, + [21] = {NO, NO, 90, 70, 75, 70, 65, NO, NO}, + [24] = {NO, NO, 75, 75, 70, 65, 60, NO, NO}, + [28] = {NO, NO, 85, 70, 65, 60, 58, NO, NO}, + [32] = {NO, NO, 80, 65, 60, 58, 55, NO, NO}, + [36] = {NO, NO, 75, 60, 58, 55, 52, NO, NO}, + [42] = {NO, NO, NO, NO, NO, NO, NO, NO, NO}, + [48] = {NO, NO, NO, NO, NO, NO, NO, NO, NO}, + [60] = {NO, NO, NO, NO, NO, NO, NO, NO, NO}, + [72] = {NO, NO, NO, NO, NO, NO, NO, NO, NO}, + [96] = {NO, NO, NO, NO, NO, NO, NO, NO, NO} + } + + return derive(function() + local matrix_to_use = + if read(needs_body) then MIN_CONTRAST_BODY + else MIN_CONTRAST + + local row_to_use = matrix_to_use[read(size)] + if not row_to_use then return NO end + local weight: Font | number = read(weight) + local font_weight: number = 400 + + if type(weight) == "number" then + font_weight = weight + elseif typeof(weight) == "Font" then + font_weight = weight.Weight.Value + end + + return row_to_use[font_weight // 100] or NO + end) + +end + +--- collapses a state to a single value +type recursive = (() -> recursive) | T +type can = (() -> T) | T +local function unwrap(source: recursive): T + + local value: recursive + + while type(source) == "function" do + source = source() + end + + value = source + + return value :: T +end + +local function get_appropriate_color(options: { + background: recursive?, + foreground: recursive<{{number}}>, + elevation: recursive?, + min_contrast: recursive, +}): () -> Color3 + + return function() + local min_contrast = unwrap(options.min_contrast) + local elevation = unwrap(options.elevation) or 0 + local bg = unwrap(options.background) + + if bg == nil then + local foreground = unwrap(options.foreground)[1] + local l, c, h = unpack(foreground, 1, 3) + local l_a, l_c, l_h = unpack(foreground, 4, 6) + local color = oklch( + l + elevation * (l_a or 0), + c + elevation * (l_c or 0), + h + elevation * (l_h or 0) + ) + return color + end + + if min_contrast == math.huge then + warn("min contrast is invalid") + end + + local max = -1 + local furthest_away = Color3.new() + + for _, foreground: {number} in unwrap(options.foreground) do + local l, c, h = unpack(foreground, 1, 3) + local l_a, l_c, l_h = unpack(foreground, 4, 6) + local color = oklch( + l + elevation * (l_a or 0), + c + elevation * (l_c or 0), + h + elevation * (l_h or 0) + ) + local contrast = math.abs(apcaw3.calcAPCA(color, bg, nil, 1, true)) + + if max < contrast then + max = contrast + furthest_away = color + end + + if contrast >= min_contrast then + return color + end + end + + -- warn(`unable to find a color, max contrast found is {max // 1} but needs at least {min_contrast}`) + return furthest_away + end + +end + +return { + + min_contrast = min_contrast, + get_appropriate_color = get_appropriate_color + +} \ No newline at end of file diff --git a/modules/Jabby/ui/util/oklch.luau b/modules/Jabby/ui/util/oklch.luau new file mode 100644 index 0000000..b32d969 --- /dev/null +++ b/modules/Jabby/ui/util/oklch.luau @@ -0,0 +1,28 @@ +local oklab = require(script.Parent.Parent.libraries.oklab) + +--[=[ + +Converts OkLCh into a Color3. + +lightness is a value between 0-1, determining how "light" a color is. + +chroma is a value between 0 to infinity, determining how colorful something is. +current displays can only display a chroma up to around 0.34, and srgb can only +go up to 0.245. + +hue is a hue circle from 0-360 + + +]=] +local function oklch(lightness: number, chroma: number, hue: number) + return oklab.linear_srgb_to_color3( + oklab.oklab_to_linear_srgb( + oklab.oklch_to_oklab( + Vector3.new(math.clamp(lightness, 0, 1), chroma, hue) + ) + ), + true + ) +end + +return oklch \ No newline at end of file diff --git a/modules/Jabby/ui/util/reduced_motion.luau b/modules/Jabby/ui/util/reduced_motion.luau new file mode 100644 index 0000000..5396b55 --- /dev/null +++ b/modules/Jabby/ui/util/reduced_motion.luau @@ -0,0 +1,3 @@ +local context = require(script.Parent.Parent.libraries.context) + +return context(false) \ No newline at end of file diff --git a/modules/Jabby/ui/util/theme.luau b/modules/Jabby/ui/util/theme.luau new file mode 100644 index 0000000..fe2db8b --- /dev/null +++ b/modules/Jabby/ui/util/theme.luau @@ -0,0 +1,253 @@ +--[[ + +simple theme engine for computing colors +]] + +local vide = require(script.Parent.Parent.Parent.vide) +local apcaw3 = require(script.Parent.Parent.libraries.apcaw3) +local contrast = require(script.Parent.contrast) +local oklch_to_color3 = require(script.Parent.oklch) + +local source = vide.source + +local function oklch(t: {number}) + return table.freeze { + l = t[1], + c = t[2], + h = t[3], + color = oklch_to_color3(unpack(t)) + } +end + +local background = source(oklch {0.2012, 0.01, 0.6}) -- note: for light mode change this to 0.9012 from 0.3012 +local accent = source(oklch {0.52, 0.21, 0.71}) -- note: for light mode change this to 0.62 from 0.52 + +local BODY_SIZE = 18 +local HEADER_SIZE = 24 +local FONT = Font.fromName( + "BuilderSans", + Enum.FontWeight.Regular, + Enum.FontStyle.Normal +) +local FONT_CODE = Font.new( + "rbxassetid://16658246179", + Enum.FontWeight.Regular, + Enum.FontStyle.Normal +) + +--- determines when it should be decreasing instead of increasing +local function should_flip(lightness: number) + return lightness > 0.65 +end + +local CONTRAST_LOW = 0.4 +local CONTRAST_HIGH = 0.7 + +type Depth = number +type ColorPalette = { + + bg: {[Depth]: Color3}, + fg_on_bg_high: {[Depth]: Color3}, + fg_on_bg_low: {[Depth]: Color3}, + fg_on_accent: {[Depth]: Color3}, + accent: Color3, + +} + +local function compute_bg(depth: Depth) + local function get() + local bg = background() + -- print(bg.l + 0.01 * depth, depth, bg.c, bg.h) + + return oklch { + math.clamp(bg.l + 0.02 * depth, 0, 1), + bg.c, + bg.h + } + -- if should_flip(bg.l) then + -- return oklch { + -- math.clamp(bg.l - 0.02 * depth, 0, 1), + -- bg.c, + -- bg.h + -- } + -- else + -- return oklch { + -- math.clamp(bg.l + 0.02 * depth, 0, 1), + -- bg.c, + -- bg.h + -- } + -- end + end + + return function(raw: boolean) + local c = if raw then get() else get().color + return c + end :: ((true) -> typeof(oklch {}) ) & (false?) -> Color3 +end + +local function compute_acc(depth: Depth) + local function get() + local bg = accent() + -- print(bg.l + 0.01 * depth, depth, bg.c, bg.h) + return oklch { + math.clamp(bg.l + 0.02 * depth, 0, 1), + bg.c, + bg.h + } + -- if should_flip(bg.l) then + -- return oklch { + -- math.clamp(bg.l - 0.02 * depth, 0, 1), + -- bg.c, + -- bg.h + -- } + -- else + -- return oklch { + -- math.clamp(bg.l + 0.02 * depth, 0, 1), + -- bg.c, + -- bg.h + -- } + -- end + end + + return function(raw: boolean) + local c = if raw then get() else get().color + return c + end :: ((true) -> typeof(oklch {}) ) & (false?) -> Color3 +end + +local function compute_fg_on_bg_high(depth: Depth) + local get_bg = compute_bg(depth) + return function() + local bg = get_bg(true) + + if should_flip(bg.l) then + return oklch { + bg.l - CONTRAST_HIGH, + bg.c, bg.h + }.color + else + return oklch { + bg.l + CONTRAST_HIGH, + bg.c, bg.h + }.color + end + end +end + +local function compute_fg_on_bg_low(depth: Depth) + local get_bg = compute_bg(depth) + return function() + local bg = get_bg(true) + + if should_flip(bg.l) then + return oklch { + bg.l - CONTRAST_LOW, + bg.c, bg.h + }.color + else + return oklch { + bg.l + CONTRAST_LOW, + bg.c, bg.h + }.color + end + end +end + +local function compute_fg_on_acc_low(depth: Depth) + local get_bg = compute_acc(depth) + return function() + local bg = get_bg(true) + + if should_flip(bg.l) then + return oklch { + bg.l - CONTRAST_LOW, + bg.c, bg.h + }.color + else + return oklch { + bg.l + CONTRAST_LOW, + bg.c, bg.h + }.color + end + end +end + +local function compute_fg_on_acc_high(depth: Depth) + local get_bg = compute_acc(depth) + return function() + local bg = get_bg(true) + + if should_flip(bg.l) then + return oklch { + bg.l - CONTRAST_HIGH, + bg.c, bg.h + }.color + else + return oklch { + bg.l + CONTRAST_HIGH, + bg.c, bg.h + }.color + end + end +end + +type Theme = { + + bg: {[Depth]: number}, + accent: Color3, + + fg_on_bg_high: {[Depth]: Color3}, + fg_on_bg_low: {[Depth]: Color3}, + fg_on_accent_high: {[Depth]: Color3}, + fg_on_accent_low: {[Depth]: Color3}, + + body: number, + header: number, + font: Font, + font_code: Font, + +} + +local function compute_theme() + + local MAX_DEPTH = 20 + local MIN_DEPTH = -20 + + local theme = { + bg = {}, + acc = {}, + + fg_on_bg_high = {}, + fg_on_bg_low = {}, + fg_on_acc_high = {}, + fg_on_acc_low = {}, + + body = BODY_SIZE, + header = HEADER_SIZE, + font = FONT, + code = FONT_CODE + } + + for i = MIN_DEPTH, MAX_DEPTH do + theme.bg[i] = compute_bg(i) + theme.acc[i] = compute_acc(i) + theme.fg_on_acc_high[i] = compute_fg_on_acc_high(i) + theme.fg_on_acc_low[i] = compute_fg_on_acc_low(i) + theme.fg_on_bg_high[i] = compute_fg_on_bg_high(i) + theme.fg_on_bg_low[i] = compute_fg_on_bg_low(i) + end + + return theme + +end + +return setmetatable({ + + settings = { + background = background, + accent = accent + }, + +}, { + __index = compute_theme() +}) \ No newline at end of file diff --git a/modules/Jabby/vide/action.luau b/modules/Jabby/vide/action.luau new file mode 100644 index 0000000..8cc4987 --- /dev/null +++ b/modules/Jabby/vide/action.luau @@ -0,0 +1,25 @@ +type Action = { + priority: number, + callback: (Instance) -> () +} + +local ActionMT = table.freeze {} + +local function is_action(v: any) + return getmetatable(v) == ActionMT +end + +local function action(callback: (Instance) -> (), priority: number?): Action + local a = { + priority = priority or 1, + callback = callback + } + + setmetatable(a :: any, ActionMT) + + return table.freeze(a) +end + +return function() + return action, is_action +end diff --git a/modules/Jabby/vide/apply.luau b/modules/Jabby/vide/apply.luau new file mode 100644 index 0000000..0f4586e --- /dev/null +++ b/modules/Jabby/vide/apply.luau @@ -0,0 +1,177 @@ +if not game then script = require "test/relative-string" end +local typeof = game and typeof or require "test/mock".typeof :: never +local Vector2 = game and Vector2 or require "test/mock".Vector2 :: never +local UDim2 = game and UDim2 or require "test/mock".UDim2 :: never + +local flags = require(script.Parent.flags) +local throw = require(script.Parent.throw) +local bind = require(script.Parent.bind) +local _, is_action = require(script.Parent.action)() +local graph = require(script.Parent.graph) +type Node = graph.Node + +type Array = { V } +type ArrayOrV = {ArrayOrV} | V +type Map = { [K]: V } + +local free_caches: { + -- event listeners to connect after properties are set + events: Map< + string, -- event name + () -> () -- listener + >, + + -- actions to run after events are connected + actions: Map< + number, -- priority + Array<(Instance) -> ()> -- action callbacks + >, + + -- cache to detect duplicate property setting at same nesting depth + nested_debug: Map< + number, -- depth + Map -- set of property names + >, + + -- use stack instead of recursive function to process nesting layers one at time + -- deeper-nested properties take precedence over shallower-nested ones + -- each nested layer occupies two indexes: 1. table ref 2. nested depth + -- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 } + nested_stack: { {} | number } +}? + +local function borrow_caches(): typeof(assert(free_caches)) + if free_caches then + local caches = free_caches :: typeof(assert(free_caches)) + free_caches = nil + return caches + else + return { + events = {}, + actions = setmetatable({} :: any, { -- lazy init + __index = function(self, i) self[i] = {}; return self[i] end + }), + nested_debug = setmetatable({} :: any, { + __index = function(self, i: number) self[i] = {}; return self[i] end + }), + nested_stack = {} + } + end +end + +local function return_caches(caches: typeof(free_caches) ) + free_caches = caches +end + +-- map of datatype names to class default constructor for aggregate init +local aggregates = {} +for name, class in { + CFrame = CFrame, + Color3 = Color3, + UDim = UDim, + UDim2 = UDim2, + Vector2 = Vector2, + Vector3 = Vector3, + Rect = Rect +} :: Map do + aggregates[name] = class.new +end + +-- applies table of nested properties to an instance using full vide semantics +local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T + if not properties then + throw("attempt to call a constructor returned by create() with no properties") + end + + local strict = flags.strict + + -- queue parent assignment if any for last + local parent: unknown = properties.Parent + + local caches = borrow_caches() + local events = caches.events + local actions = caches.actions + local nested_debug = caches.nested_debug + local nested_stack = caches.nested_stack + + -- process all properties + local depth = 1 + repeat + for property, value in properties do + if property == "Parent" then continue end + + if type(property) == "string" then + if strict then -- check for duplicate prop assignment at nesting depth + if nested_debug[depth][property] then + throw(`duplicate property {property} at depth {depth}`) + end + nested_debug[depth][property] = true + end + + if type(value) == "table" then -- attempt aggregate init + local ctor = aggregates[typeof((instance :: any)[property])] + if ctor == nil then + throw(`cannot aggregate type {typeof(value)} for property {property}`) + end + (instance :: any)[property] = ctor(unpack(value :: {})) + elseif type(value) == "function" then + if typeof((instance :: any)[property]) == "RBXScriptSignal" then + events[property] = value :: () -> () -- add event to buffer + else + bind.property(instance, property, value :: () -> ()) -- bind property + end + else + (instance :: any)[property] = value -- set property + end + elseif type(property) == "number" then + if type(value) == "function" then + bind.children(instance, value :: () -> ArrayOrV) -- bind children + elseif type(value) == "table" then + if is_action(value) then + table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer + else + table.insert(nested_stack, value :: {}) + table.insert(nested_stack, depth + 1) -- push table to stack for later processing + end + else + (value :: Instance).Parent = instance -- parent child + end + end + end + + depth = table.remove(nested_stack) :: number + properties = table.remove(nested_stack) :: {} + + until not properties + + for event, listener in next, events do + (instance :: any)[event]:Connect(listener) + end + + for _, queued in next, actions do + for _, callback in next, queued do + callback(instance) + end + end + + -- finally set parent if any + if parent then + if type(parent) == "function" then + bind.parent(instance, parent :: () -> Instance) + else + instance.Parent = parent :: Instance + end + end + + -- clear caches + table.clear(events) + for _, queued in next, actions do table.clear(queued) end + if strict then table.clear(nested_debug) end + table.clear(nested_stack) + + return_caches(caches) + + return instance +end + +return apply diff --git a/modules/Jabby/vide/batch.luau b/modules/Jabby/vide/batch.luau new file mode 100644 index 0000000..45ab038 --- /dev/null +++ b/modules/Jabby/vide/batch.luau @@ -0,0 +1,26 @@ +if not game then script = require "test/relative-string" end + +local flags = require(script.Parent.flags) +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) + +local function batch(setter: () -> ()) + local already_batching = flags.batch + local from + + if not already_batching then + flags.batch = true + from = graph.get_update_queue_length() + end + + local ok, err: string? = pcall(setter) + + if not already_batching then + flags.batch = false + graph.flush_update_queue(from) + end + + if not ok then throw(`error occured while batching updates: {err}`) end +end + +return batch diff --git a/modules/Jabby/vide/bind.luau b/modules/Jabby/vide/bind.luau new file mode 100644 index 0000000..3a016a6 --- /dev/null +++ b/modules/Jabby/vide/bind.luau @@ -0,0 +1,105 @@ +if not game then script = require "test/relative-string" end + +local graph = require(script.Parent.graph) +type Node = graph.Node +local create_node = graph.create_node +local assert_stable_scope = graph.assert_stable_scope +local evaluate_node = graph.evaluate_node + +function create_implicit_effect(updater: (T) -> T, binding: T) + evaluate_node(create_node(assert_stable_scope(), updater, binding)) +end + +type PropertyBinding = { + instance: Instance, + property: string, + source: () -> unknown +} + +local function update_property_effect(p: PropertyBinding) + (p.instance :: any)[p.property] = p.source() + return p +end + +type ParentBinding = { + instance: Instance, + parent: () -> Instance +} + +local function update_parent_effect(p: ParentBinding) + p.instance.Parent = p.parent() + return p +end + +type ChildrenBinding = { + instance: Instance, + cur_children_set: { [Instance]: true }, + new_children_set: { [Instance]: true }, + children: () -> Instance | { Instance } +} + +type ArrayOrV = V | { V } +local function update_children_effect(p: ChildrenBinding) + local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update + local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update + + local new_children = p.children() -- all (and only) children that should be parented after this update + + if type(new_children) ~= "table" then + new_children = { new_children } + end + + local function process_child(child: ArrayOrV) + if type(child) == "table" then + for _, child in next, child do + process_child(child) + end + else + if new_child_set[child] then return end -- stops redundant reparenting + + new_child_set[child] = true -- record child set from this update + if not cur_children_set[child] then + child.Parent = p.instance -- if child wasn't already parented then parent it + else + cur_children_set[child] = nil -- remove child from cache if it was already in cache + end + end + end + + process_child(new_children) + + for child in next, cur_children_set do + child.Parent = nil -- unparent all children that weren't in the new children set + end + + table.clear(cur_children_set) -- clear cache, preserve capacity + p.cur_children_set, p.new_children_set = new_child_set, cur_children_set + + return p +end + +return { + property = function(instance, property, source) + return create_implicit_effect(update_property_effect, { + instance = instance, + property = property, + source = source + }) + end, + + parent = function(instance, parent) + return create_implicit_effect(update_parent_effect, { + instance = instance, + parent = parent + }) + end, + + children = function(instance, children) + return create_implicit_effect(update_children_effect, { + instance = instance, + cur_children_set = {}, + new_children_set = {}, + children = children + }) + end +} diff --git a/modules/Jabby/vide/changed.luau b/modules/Jabby/vide/changed.luau new file mode 100644 index 0000000..519a554 --- /dev/null +++ b/modules/Jabby/vide/changed.luau @@ -0,0 +1,20 @@ +if not game then script = require "test/relative-string" end + +local action = require(script.Parent.action)() +local cleanup = require(script.Parent.cleanup) + +local function changed(property: string, callback: (T) -> ()) + return action(function(instance) + local con = instance:GetPropertyChangedSignal(property):Connect(function() + callback((instance :: any)[property]) + end) + + cleanup(function() + con:Disconnect() + end) + + callback((instance :: any)[property]) + end) +end + +return changed diff --git a/modules/Jabby/vide/cleanup.luau b/modules/Jabby/vide/cleanup.luau new file mode 100644 index 0000000..8449fcc --- /dev/null +++ b/modules/Jabby/vide/cleanup.luau @@ -0,0 +1,43 @@ +if not game then script = require "test/relative-string" end +local typeof = game and typeof or require "test/mock".typeof :: never + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +local get_scope = graph.get_scope +local push_cleanup = graph.push_cleanup + +local function helper(obj: any) + return + if typeof(obj) == "RBXScriptConnection" then function() obj:Disconnect() end + elseif typeof(obj) == "Instance" then function() obj:Destroy() end + elseif obj.destroy then function() obj:destroy() end + elseif obj.disconnect then function() obj:disconnect() end + elseif obj.Destroy then function() obj:Destroy() end + elseif obj.Disconnect then function() obj:Disconnect() end + else throw("cannot cleanup given object") +end + +local function cleanup(value: unknown) + local scope = get_scope() + + if not scope then + throw "cannot cleanup outside a stable or reactive scope" + end; assert(scope) + + if type(value) == "function" then + push_cleanup(scope, value :: () -> ()) + else + push_cleanup(scope, helper(value)) + end +end + +type Destroyable = { destroy: (any) -> () } | { Destroy: (any) -> () } +type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () } + +return cleanup :: + ( (callback: () -> ()) -> () ) & + ( (instance: Destroyable) -> () ) & + ( (connection: Disconnectable) -> () ) & + ( (instance: Instance) -> () ) & + ( (connection: RBXScriptConnection) -> () ) + diff --git a/modules/Jabby/vide/context.luau b/modules/Jabby/vide/context.luau new file mode 100644 index 0000000..6c571d0 --- /dev/null +++ b/modules/Jabby/vide/context.luau @@ -0,0 +1,77 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +type Node = graph.Node +local create_node = graph.create_node +local get_scope = graph.get_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local set_context = graph.set_context + +export type Context = (() -> T) & ((T, () -> U) -> U) + +local nil_symbol = newproxy() +local count = 0 + +local function context(...: T): Context + count += 1 + local id = count + + local has_default = select("#", ...) > 0 + local default_value = ... + + return function(...): any -- todo: fix type error + local scope: Node? | false = get_scope() + + if select("#", ...) == 0 then -- get + while scope do + local ctx = scope.context + + if not ctx then + scope = scope.owner + continue + end + + local value = (ctx :: { unknown })[id] + + if value == nil then + scope = scope.owner + continue + end + + return (if value ~= nil_symbol then value else nil) :: T + end + + if has_default ~= nil then + return default_value + else + throw("attempt to get context when no context is set and no default context is set") + end + else -- set + if not scope then return throw("attempt to set context outside of a vide scope") end + + local value, component = ... + + local new_scope = create_node(scope, false, false) + set_context(new_scope, id, if value == nil then nil_symbol else value) + + push_scope(new_scope) + + local function efn(err: string) return debug.traceback(err, 3) end + local ok, result = xpcall(component, efn) + + pop_scope() + + if not ok then + throw(`error while running context:\n\n{result}`) + end + + return result + end + + return nil :: any + end +end + +return context diff --git a/modules/Jabby/vide/create.luau b/modules/Jabby/vide/create.luau new file mode 100644 index 0000000..7adfc68 --- /dev/null +++ b/modules/Jabby/vide/create.luau @@ -0,0 +1,84 @@ +if not game then script = require "test/relative-string" end +local typeof = game and typeof or require "test/mock".typeof:: never +local Instance = game and Instance or require "test/mock".Instance :: never + +local throw = require(script.Parent.throw) +local defaults = require(script.Parent.defaults) +local apply = require(script.Parent.apply) + +local ctor_cache = {} :: { [string]: () -> Instance } + +setmetatable(ctor_cache :: any, { + __index = function(self, class) + local ok, instance: Instance = pcall(Instance.new, class :: any) + if not ok then throw(`invalid class name, could not create instance of class { class }`) end + + local default: { [string]: unknown }? = defaults[class] + if default then + for i, v in next, default do + (instance :: any)[i] = v + end + end + + local function ctor(properties: Props): Instance + return apply(instance:Clone(), properties) + end + + self[class] = ctor + return ctor + end +}) + +local function create_instance(class: string) + return ctor_cache[class] +end + +local function clone_instance(instance: Instance) + return function(properties: Props): Instance + local clone = instance:Clone() + if not clone then throw "attempt to clone a non-archivable instance" end + return apply(clone, properties) + end +end + +local function create(class_or_instance: string|Instance): (Props) -> Instance + if type(class_or_instance) == "string" then + return create_instance(class_or_instance) + elseif typeof(class_or_instance) == "Instance" then + return clone_instance(class_or_instance) + else + throw("bad argument #1, expected string or instance, got " .. typeof(class_or_instance)) + return nil :: never + end +end + +type Props = { [any]: any } +return (create :: any) :: +( (T & Instance) -> (Props) -> T ) & +( ("Folder") -> (Props) -> Folder ) & +( ("BillboardGui") -> (Props) -> BillboardGui ) & +( ("CanvasGroup") -> (Props) -> CanvasGroup ) & +( ("Frame") -> (Props) -> Frame ) & +( ("ImageButton") -> (Props) -> ImageButton ) & +( ("ImageLabel") -> (Props) -> ImageLabel ) & +( ("ScreenGui") -> (Props) -> ScreenGui ) & +( ("ScrollingFrame") -> (Props) -> ScrollingFrame ) & +( ("SurfaceGui") -> (Props) -> SurfaceGui ) & +( ("TextBox") -> (Props) -> TextBox ) & +( ("TextButton") -> (Props) -> TextButton ) & +( ("TextLabel") -> (Props) -> TextLabel ) & +( ("UIAspectRatioConstraint") -> (Props) -> UIAspectRatioConstraint ) & +( ("UICorner") -> (Props) -> UICorner ) & +( ("UIGradient") -> (Props) -> UIGradient ) & +( ("UIGridLayout") -> (Props) -> UIGridLayout ) & +( ("UIListLayout") -> (Props) -> UIListLayout ) & +( ("UIPadding") -> (Props) -> UIPadding ) & +( ("UIPageLayout") -> (Props) -> UIPageLayout ) & +( ("UIScale") -> (Props) -> UIScale ) & +( ("UISizeConstraint") -> (Props) -> UISizeConstraint ) & +( ("UIStroke") -> (Props) -> UIStroke ) & +( ("UITableLayout") -> (Props) -> UITableLayout ) & +( ("UITextSizeConstraint") -> (Props) -> UITextSizeConstraint ) & +( ("VideoFrame") -> (Props) -> VideoFrame ) & +( ("ViewportFrame") -> (Props) -> ViewportFrame ) & +( (string) -> (Props) -> Instance ) diff --git a/modules/Jabby/vide/defaults.luau b/modules/Jabby/vide/defaults.luau new file mode 100644 index 0000000..03badd6 --- /dev/null +++ b/modules/Jabby/vide/defaults.luau @@ -0,0 +1,114 @@ +local Enum = game and Enum or require "test/mock".Enum :: never +local Color3 = game and Color3 or require "test/mock".Color3 :: never +local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never + +return { + Part = { + Material = Enum.Material.SmoothPlastic, + Size = Vector3.new(1, 1, 1), + Anchored = true + }, + + BillboardGui = { + ResetOnSpawn = false, + ZIndexBehavior = Enum.ZIndexBehavior.Sibling + }, + + CanvasGroup = nil, + + Frame = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0 + }, + + ImageButton = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + AutoButtonColor = false + }, + + ImageLabel = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + }, + + ScreenGui = { + ResetOnSpawn = false, + ZIndexBehavior = Enum.ZIndexBehavior.Sibling + }, + + ScrollingFrame = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + ScrollBarImageColor3 = Color3.new(0, 0, 0) + }, + + SurfaceGui = { + ResetOnSpawn = false, + ZIndexBehavior = Enum.ZIndexBehavior.Sibling, + + PixelsPerStud = 50, + SizingMode = Enum.SurfaceGuiSizingMode.PixelsPerStud + }, + + TextBox = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + ClearTextOnFocus = false, + Font = Enum.Font.SourceSans, + Text = "", + TextColor3 = Color3.new(0, 0, 0) + }, + + TextButton = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + AutoButtonColor = false, + Font = Enum.Font.SourceSans, + Text = "", + TextColor3 = Color3.new(0, 0, 0) + }, + + TextLabel = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + Font = Enum.Font.SourceSans, + Text = "", + TextColor3 = Color3.new(0, 0, 0) + }, + + UIListLayout = { + SortOrder = Enum.SortOrder.LayoutOrder + }, + + UIGridLayout = { + SortOrder = Enum.SortOrder.LayoutOrder + }, + + UITableLayout = { + SortOrder = Enum.SortOrder.LayoutOrder + }, + + UIPageLayout = { + SortOrder = Enum.SortOrder.LayoutOrder + }, + + VideoFrame = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0 + }, + + ViewportFrame = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0 + } +} diff --git a/modules/Jabby/vide/derive.luau b/modules/Jabby/vide/derive.luau new file mode 100644 index 0000000..fb824a4 --- /dev/null +++ b/modules/Jabby/vide/derive.luau @@ -0,0 +1,20 @@ +if not game then script = require "test/relative-string" end + +local graph = require(script.Parent.graph) +local create_node = graph.create_node +local push_child_to_scope = graph.push_child_to_scope +local assert_stable_scope = graph.assert_stable_scope +local evaluate_node = graph.evaluate_node + +local function derive(source: () -> T): () -> T + local node = create_node(assert_stable_scope(), source, false :: any) + + evaluate_node(node) + + return function() + push_child_to_scope(node) + return node.cache + end +end + +return derive diff --git a/modules/Jabby/vide/effect.luau b/modules/Jabby/vide/effect.luau new file mode 100644 index 0000000..3acab21 --- /dev/null +++ b/modules/Jabby/vide/effect.luau @@ -0,0 +1,14 @@ +if not game then script = require "test/relative-string" end + +local graph = require(script.Parent.graph) +local create_node = graph.create_node +local assert_stable_scope = graph.assert_stable_scope +local evaluate_node = graph.evaluate_node + +local function effect(callback: (T) -> T, initial_value: T) + local node = create_node(assert_stable_scope(), callback, initial_value) + + evaluate_node(node) +end + +return effect :: ((callback: (T) -> T, initial_value: T) -> ()) & ((callback: () -> ()) -> ()) diff --git a/modules/Jabby/vide/flags.luau b/modules/Jabby/vide/flags.luau new file mode 100644 index 0000000..cc2d2f8 --- /dev/null +++ b/modules/Jabby/vide/flags.luau @@ -0,0 +1,7 @@ +local function inline_test(): string + return debug.info(1, "n") +end + +local is_O2 = inline_test() ~= "inline_test" + +return { strict = not is_O2, batch = false } diff --git a/modules/Jabby/vide/graph.luau b/modules/Jabby/vide/graph.luau new file mode 100644 index 0000000..9648180 --- /dev/null +++ b/modules/Jabby/vide/graph.luau @@ -0,0 +1,300 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local flags = require(script.Parent.flags) + +export type SourceNode = { + cache: T, + [number]: Node +} + +export type Node = { + cache: T, + effect: ((T) -> T) | false, + cleanups: { () -> () } | false, + + context: { [number]: unknown } | false, + + owned: { Node } | false, + owner: Node | false, + + parents: { SourceNode }, + [number]: Node -- children +} + +local scopes = { n = 0 } :: { [number]: Node, n: number } -- scopes stack + +local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) + local thread = coroutine.create(xpcall) + local function efn(err: string) return debug.traceback(err, 3) end + local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg) + + assert(resume_ok) + + if coroutine.status(thread) ~= "dead" then + return false, debug.traceback(thread, "attempt to yield in reactive scope") + end + + return run_ok, result +end + +local function get_scope(): Node? + return scopes[scopes.n] +end + +local function assert_stable_scope(): Node + local scope = get_scope() + + if not scope then + local caller_name = debug.info(2, "n") + return throw(`cannot use {caller_name}() outside a stable or reactive scope`) + elseif scope.effect then + throw("cannot create a new reactive scope inside another reactive scope") + end + + return scope +end + +local function push_child(parent: SourceNode, child: Node) + table.insert(parent, child) + table.insert(child.parents, parent) +end + +local function push_scope(node: Node) + local n = scopes.n + 1 + scopes.n = n + scopes[n] = node +end + +local function pop_scope() + local n = scopes.n + scopes.n = n - 1 + scopes[n] = nil +end + +local function push_cleanup(node: Node, cleanup: () -> ()) + if node.cleanups then + table.insert(node.cleanups, cleanup) + else + node.cleanups = { cleanup } + end +end + +local function flush_cleanups(node: Node) + if node.cleanups then + for _, fn in next, node.cleanups do + local ok, err: string? = pcall(fn) + if not ok then throw(`cleanup error: {err}`) end + end + + table.clear(node.cleanups) + end +end + +local function find_and_swap_pop(t: { T }, v: T) + local i = table.find(t, v) :: number + local n = #t + t[i] = t[n] + t[n] = nil +end + +local function unparent(node: Node) + local parents = node.parents + + for i, parent in parents do + find_and_swap_pop(parent, node) + parents[i] = nil + end +end + +local function destroy(node: Node) + flush_cleanups(node) + unparent(node) + + if node.owner then + find_and_swap_pop(node.owner.owned :: { Node }, node) + node.owner = false + end + + if node.owned then + local owned = node.owned + while owned[1] do destroy(owned[1]) end + end +end + +local function destroy_owned(node: Node) + if node.owned then + local owned = node.owned + while owned[1] do destroy(owned[1]) end + end +end + +local update_queue = { n = 0 } :: { n: number, [number]: Node } + +local function evaluate_node(node: Node) + if flags.strict then + local initial_value = node.cache + + for i = 1, 2 do + local cur_value = node.cache + + flush_cleanups(node) + destroy_owned(node) + + push_scope(node) + local ok, new_value = ycall(node.effect :: (T) -> T, cur_value) + pop_scope() + + if not ok then + table.clear(update_queue) + update_queue.n = 0 + throw(`effect stacktrace:\n{new_value :: string}`) + end + + node.cache = new_value :: T + end + + return initial_value ~= node.cache + else + local cur_value = node.cache + + flush_cleanups(node) + destroy_owned(node) + + push_scope(node) + local ok, new_value = pcall(node.effect :: (T) -> T, node.cache) + pop_scope() + + if not ok then + table.clear(update_queue) + update_queue.n = 0 + throw(`effect stacktrace:\n{new_value}\n`) + end + + node.cache = new_value + return cur_value ~= new_value + end +end + +local function queue_children_for_update(node: SourceNode) + local i = update_queue.n + while node[1] do + i += 1 + update_queue[i] = node[1] + unparent(node[1]) + end + update_queue.n = i +end + +local function get_update_queue_length() + return update_queue.n +end + +local function flush_update_queue(from: number) + local i = from + 1 + while i <= update_queue.n do + local node = update_queue[i] + --assert(node.effect) + + if node.owner and evaluate_node(node) then + queue_children_for_update(node) + end + + update_queue[i] = false :: any + i += 1 + end + + update_queue.n = from +end + +local function update_descendants(root: SourceNode) + local n0 = update_queue.n + queue_children_for_update(root) + + if flags.batch then return end + + local i = n0 + 1 + while i <= update_queue.n do + local node = update_queue[i] + --assert(node.effect) + + -- check if node is still owned in case destroyed after queued + if node.owner and evaluate_node(node) then + queue_children_for_update(node) + end + + update_queue[i] = false :: any -- false instead of nil to avoid sparse + i += 1 + end + + update_queue.n = n0 +end + +local function push_child_to_scope(node: SourceNode) + local scope = get_scope() + if scope and scope.effect then -- do not track nodes with no effect + push_child(node, scope) + end +end + +local function create_node(owner: false | Node, effect: false | (T) -> T, value: T): Node + local node: Node = { + cache = value, + effect = effect, + cleanups = false, + + context = false, + + owner = owner, + owned = false, + + parents = {}, + } + + if owner then + if owner.owned then + table.insert(owner.owned, node) + else + owner.owned = { node } + end + end + + return node +end + +local function create_source_node(value: T): SourceNode + return { cache = value } +end + +local function get_children(node: Node): { Node } + return { unpack(node) } :: { Node } +end + +local function set_context(node: Node, key: number, value: unknown) + if node.context then + node.context[key] = value + else + node.context = { [key] = value } + end +end + +return table.freeze { + push_scope = push_scope, + pop_scope = pop_scope, + evaluate_node = evaluate_node, + get_scope = get_scope, + assert_stable_scope = assert_stable_scope, + push_cleanup = push_cleanup, + destroy = destroy, + flush_cleanups = flush_cleanups, + push_child_to_scope = push_child_to_scope, + update_descendants = update_descendants, + push_child = push_child, + create_node = create_node, + create_source_node = create_source_node, + get_children = get_children, + flush_update_queue = flush_update_queue, + get_update_queue_length = get_update_queue_length, + set_context = set_context, + scopes = scopes +} diff --git a/modules/Jabby/vide/init.luau b/modules/Jabby/vide/init.luau new file mode 100644 index 0000000..3bcc7ad --- /dev/null +++ b/modules/Jabby/vide/init.luau @@ -0,0 +1,121 @@ +-------------------------------------------------------------------------------- +-- vide.luau +-------------------------------------------------------------------------------- + +local version = { major = 0, minor = 3, patch = 1 } + +if not game then script = require "test/relative-string" end + +local root = require(script.root) +local mount = require(script.mount) +local create = require(script.create) +local apply = require(script.apply) +local source = require(script.source) +local effect = require(script.effect) +local derive = require(script.derive) +local cleanup = require(script.cleanup) +local untrack = require(script.untrack) +local read = require(script.read) +local batch = require(script.batch) +local context = require(script.context) +local switch = require(script.switch) +local show = require(script.show) +local indexes, values = require(script.maps)() +local spring, update_springs = require(script.spring)() +local action = require(script.action)() +local changed = require(script.changed) +local throw = require(script.throw) +local flags = require(script.flags) + +export type Source = source.Source +export type source = Source +export type Context = context.Context +export type context = Context + +local function step(dt: number) + if game then + debug.profilebegin("VIDE STEP") + debug.profilebegin("VIDE SPRING") + end + + update_springs(dt) + + if game then + debug.profileend() + debug.profileend() + end +end + +local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number) + task.defer(step, dt) +end) + +local vide = { + version = version, + + -- core + root = root, + mount = mount, + create = create, + source = source, + effect = effect, + derive = derive, + switch = switch, + show = show, + indexes = indexes, + values = values, + + -- util + cleanup = cleanup, + untrack = untrack, + read = read, + batch = batch, + context = context, + + -- animations + spring = spring, + + -- actions + action = action, + changed = changed, + + -- flags + strict = (nil :: any) :: boolean, + + -- temporary + apply = function(instance: Instance) + return function(props: { [any]: any }) + apply(instance, props) + return instance + end + end, + + -- runtime + step = function(dt: number) + if stepped then + stepped:Disconnect() + stepped = nil + end + step(dt) + end +} + +setmetatable(vide :: any, { + __index = function(_, index: unknown): () + if index == "strict" then + return flags.strict + else + throw(`{tostring(index)} is not a valid member of vide`) + end + end, + + __newindex = function(_, index: unknown, value: unknown) + if index == "strict" then + flags.strict = value :: boolean + else + throw(`{tostring(index)} is not a valid member of vide`) + end + end +}) + +return vide diff --git a/modules/Jabby/vide/maps.luau b/modules/Jabby/vide/maps.luau new file mode 100644 index 0000000..23c1eb3 --- /dev/null +++ b/modules/Jabby/vide/maps.luau @@ -0,0 +1,219 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local flags = require(script.Parent.flags) +local graph = require(script.Parent.graph) +type Node = graph.Node +type SourceNode = graph.SourceNode +local create_node = graph.create_node +local create_source_node = graph.create_source_node +local push_child_to_scope = graph.push_child_to_scope +local update_descendants = graph.update_descendants +local assert_stable_scope = graph.assert_stable_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local evaluate_node = graph.evaluate_node +local destroy = graph.destroy + +type Map = { [K]: V } + +local function check_primitives(t: {}) + if not flags.strict then return end + + for _, v in next, t do + if type(v) == "table" or type(v) == "userdata" or type(v) == "function" then continue end + throw("table source map cannot return primitives") + end +end + +local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } + local owner = assert_stable_scope() + local subowner = create_node(owner, false, false) + + local input_cache = {} :: Map + local output_cache = {} :: Map + local input_nodes = {} :: Map> + local remove_queue = {} :: { K } + local scopes = {} :: Map> + + local function update_children(data) + -- queue removed values + for i in next, input_cache do + if data[i] == nil then + table.insert(remove_queue, i) + end + end + + -- remove queued values + for _, i in next, remove_queue do + destroy(scopes[i]) + + input_cache[i] = nil + output_cache[i] = nil + input_nodes[i] = nil + scopes[i] = nil + end + + table.clear(remove_queue) + + push_scope(subowner) + + -- process new or changed values + for i, v in next, data do + local cv = input_cache[i] + + if cv ~= v then + if cv == nil then -- create new scope and run transform + local scope = create_node(subowner, false, false) + scopes[i] = scope :: Node + + local node = create_source_node(v) + + push_scope(scope) + + local ok, result = pcall(transform, function() + push_child_to_scope(node) + return node.cache + end, i) + + pop_scope() + + if not ok then + pop_scope() -- subowner scope + error(result, 0) + end + + input_nodes[i] = node + output_cache[i] = result + else -- update source + input_nodes[i].cache = v + update_descendants(input_nodes[i]) + end + + input_cache[i] = v + end + end + + pop_scope() + + local output_array = table.create(#scopes) + for _, v in next, output_cache do + table.insert(output_array, v) + end + check_primitives(output_array) + + return output_array + end + + local node = create_node(owner, function() + return update_children(input()) + end, false :: any) + + evaluate_node(node) + + return function() + push_child_to_scope(node) + return node.cache + end +end + +local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } + local owner = assert_stable_scope() + local subowner = create_node(owner, false, false) + + local cur_input_cache_up = {} :: Map + local new_input_cache_up = {} :: Map + local output_cache = {} :: Map + local input_nodes = {} :: Map> + local scopes = {} :: Map> + + local function update_children(data: Map) + local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up + + if flags.strict then + local cache = {} + for _, v in next, data do + if cache[v] ~= nil then + throw "duplicate table value detected" + end + cache[v] = true + end + end + + push_scope(subowner) + + -- process data + for i, v in next, data do + new_input_cache[v] = i + + local cv = cur_input_cache[v] + + if cv == nil then -- create new scope and run transform + local scope = create_node(subowner, false, false) + scopes[v] = scope :: Node + + local node = create_source_node(i) + + push_scope(scope) + + local ok, result = pcall(transform, v, function() + push_child_to_scope(node) + return node.cache + end) + + pop_scope() + + if not ok then + pop_scope() -- subowner scope + error(result, 0) + end + + input_nodes[v] = node + output_cache[v] = result + else -- update source + if cv ~= i then + input_nodes[v].cache = i + update_descendants(input_nodes[v]) + end + + cur_input_cache[v] = nil + end + end + + pop_scope() + + -- remove old values + for v in next, cur_input_cache do + destroy(scopes[v]) + + output_cache[v] = nil + input_nodes[v] = nil + scopes[v] = nil + end + + -- update buffer cache + table.clear(cur_input_cache) + cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache + + local output_array = table.create(#scopes) + for _, v in next, output_cache do + table.insert(output_array, v) + end + check_primitives(output_array) + + return output_array + end + + local node = create_node(owner, function() + return update_children(input()) + end, false :: any) + + evaluate_node(node) + + return function() + push_child_to_scope(node) + return node.cache + end +end + +return function() return indexes, values end diff --git a/modules/Jabby/vide/mount.luau b/modules/Jabby/vide/mount.luau new file mode 100644 index 0000000..b9d0ace --- /dev/null +++ b/modules/Jabby/vide/mount.luau @@ -0,0 +1,13 @@ +if not game then script = require "test/relative-string" end + +local root = require(script.Parent.root) +local apply = require(script.Parent.apply) + +local function mount(component: () -> T, target: Instance?): () -> () + return root(function() + local result = component() + if target then apply(target, { result }) end + end) +end + +return mount :: ((component: () -> T, target: Instance) -> () -> ()) & ((component: () -> ()) -> () -> ()) diff --git a/modules/Jabby/vide/read.luau b/modules/Jabby/vide/read.luau new file mode 100644 index 0000000..d3a2fb7 --- /dev/null +++ b/modules/Jabby/vide/read.luau @@ -0,0 +1,7 @@ +if not game then script = require "test/relative-string" end + +local function read(value: T | () -> T): T + return if type(value) == "function" then value() else value +end + +return read diff --git a/modules/Jabby/vide/root.luau b/modules/Jabby/vide/root.luau new file mode 100644 index 0000000..bc5904d --- /dev/null +++ b/modules/Jabby/vide/root.luau @@ -0,0 +1,39 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +type Node = graph.Node +local create_node = graph.create_node +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local destroy = graph.destroy + +local refs = {} + +local function root(fn: (destroy: () -> ()) -> T...): (() -> (), T...) + local node = create_node(false, false, false) + + refs[node] = true -- prevent gc of root node + + local destroy = function() + if not refs[node] then throw "root already destroyed" end + refs[node] = nil + destroy(node) + end + + push_scope(node) + + local function efn(err: string) return debug.traceback(err, 3) end + local result = { xpcall(fn, efn, destroy) } + + pop_scope() + + if not result[1] then + destroy() + throw(`error while running root():\n\n{result[2]}`) + end + + return destroy, unpack(result :: any, 2) +end + +return root :: (fn: (destroy: () -> ()) -> T...) -> (() -> (), T...) diff --git a/modules/Jabby/vide/show.luau b/modules/Jabby/vide/show.luau new file mode 100644 index 0000000..1cf60ca --- /dev/null +++ b/modules/Jabby/vide/show.luau @@ -0,0 +1,18 @@ +if not game then script = require "test/relative-string" end + +local switch = require(script.Parent.switch) + +local function show(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T? + local function truthy() + return not not source() + end + + return switch(truthy) { + [true] = component, + [false] = fallback, + } +end + +return show :: + ((source: () -> any, component: () -> T) -> () -> T?) & + ((source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?) diff --git a/modules/Jabby/vide/source.luau b/modules/Jabby/vide/source.luau new file mode 100644 index 0000000..3aa44d9 --- /dev/null +++ b/modules/Jabby/vide/source.luau @@ -0,0 +1,31 @@ +if not game then script = require "test/relative-string" end + +local graph = require(script.Parent.graph) +type Node = graph.Node +local create_source_node = graph.create_source_node +local push_child_to_scope = graph.push_child_to_scope +local update_descendants = graph.update_descendants + +export type Source = (value: T?) -> T + +local function source(initial_value: T): Source + local node = create_source_node(initial_value) + + return function(...): T + if select("#", ...) == 0 then -- no args were given + push_child_to_scope(node) + return node.cache + end + + local v = ... :: T + if node.cache == v and (type(v) ~= "table" or table.isfrozen(v)) then + return v + end + + node.cache = v + update_descendants(node) + return v + end +end + +return source :: ((initial_value: T) -> Source) & (() -> Source) diff --git a/modules/Jabby/vide/spring.luau b/modules/Jabby/vide/spring.luau new file mode 100644 index 0000000..a01ac1c --- /dev/null +++ b/modules/Jabby/vide/spring.luau @@ -0,0 +1,311 @@ +if not game then script = require "test/relative-string" end +local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never + +--[[ + +Supported datatypes: +- number +- CFrame +- Color3 +- UDim +- UDim2 +- Vector2 +- Vector3 +- Rect + +Unsupported datatypes: +- bool +- Vector2int16 +- Vector3int16 +- EnumItem + +]] + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +type Node = graph.Node +type SourceNode = graph.SourceNode +local create_node = graph.create_node +local create_source_node = graph.create_source_node +local assert_stable_scope = graph.assert_stable_scope +local evaluate_node = graph.evaluate_node +local update_descendants = graph.update_descendants +local push_child_to_scope = graph.push_child_to_scope + +local UPDATE_RATE = 120 +local TOLERANCE = 0.0001 + +type Vec3 = Vector3 + +local function Vec3(x: number?, y: number?, z: number?) + return Vector3.new(x, y, z) +end + +local ZERO = Vec3(0, 0, 0) + +type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 + +type SpringData = { + k: number, -- spring constant + c: number, -- damping coeff + + -- dimensions 1-3 + x0_123: Vec3, + x1_123: Vec3, + v_123: Vec3, + + -- dimensions 4-6 + x0_456: Vec3, + x1_456: Vec3, + v_456: Vec3, + + source_value: T -- current value of spring input source +} + +type TypeToVec6 = (T) -> (Vec3, Vec3) +type Vec6ToType = (Vec3, Vec3) -> T + +local type_to_vec6 = { + number = function(v) + return Vec3(v, 0, 0), ZERO + end :: TypeToVec6, + + CFrame = function(v) + return v.Position, Vec3(v:ToEulerAnglesXYZ()) + end :: TypeToVec6, + + Color3 = function(v) + -- todo: hsv, oklab? + return Vec3(v.R, v.G, v.B), ZERO + end :: TypeToVec6, + + UDim = function(v) + return Vec3(v.Scale, v.Offset, 0), ZERO + end :: TypeToVec6, + + UDim2 = function(v) + return Vec3(v.X.Scale, v.X.Offset, v.Y.Scale), Vec3(v.Y.Offset, 0, 0) + end :: TypeToVec6, + + Vector2 = function(v) + return Vec3(v.X, v.Y, 0), ZERO + end :: TypeToVec6, + + Vector3 = function(v) + return v, ZERO + end :: TypeToVec6, + + Rect = function(v) + return Vec3(v.Min.X, v.Min.Y, v.Max.X), Vec3(v.Max.Y, 0, 0) + end :: TypeToVec6 +} + +local vec6_to_type = { + number = function(a, b) + return a.X + end :: Vec6ToType, + + CFrame = function(a, b) + return CFrame.new(a) * CFrame.fromEulerAnglesXYZ(b.X, b.Y, b.Z) + end :: Vec6ToType, + + Color3 = function(v) + return Color3.new(math.clamp(v.X, 0, 1), math.clamp(v.Y, 0, 1), math.clamp(v.Z, 0, 1)) + end :: Vec6ToType, + + UDim = function(v) + return UDim.new(v.X, math.round(v.Y)) + end :: Vec6ToType, + + UDim2 = function(a, b) + return UDim2.new(a.X, math.round(a.Y), a.Z, math.round(b.X)) + end :: Vec6ToType, + + Vector2 = function(v) + return Vector2.new(v.X, v.Y) + end :: Vec6ToType, + + Vector3 = function(v) + return v + end :: Vec6ToType, + + Rect = function(a, b) + return Rect.new(a.X, a.Y, a.Z, b.X) + end :: Vec6ToType +} + +local invalid_type = { + __index = function(_, t: string) + throw(`cannot spring type {t}`) + end +} + +setmetatable(type_to_vec6, invalid_type) +setmetatable(vec6_to_type, invalid_type) + +-- maps spring data to its corresponding output node +-- lifetime of spring data is tied to output node +local springs: { [SpringData]: SourceNode } = {} +setmetatable(springs, { __mode = "v" }) + +local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T + local owner = assert_stable_scope() + + -- https://en.wikipedia.org/wiki/Damping + + local w_n = 2*math.pi / (period or 1) + local z = damping_ratio or 1 + + local k = w_n^2 + local c_c = 2*w_n + local c = z * c_c + + -- todo: is there a solution other than reducing step size? + -- todo: this does not catch all solver exploding cases + if c > UPDATE_RATE*2 then -- solver will explode if this is true + throw("spring damping too high, consider reducing damping or increasing period") + end + + local data: SpringData = { + k = k, + c = c, + + x0_123 = ZERO, + x1_123 = ZERO, + v_123 = ZERO, + + x0_456 = ZERO, + x1_456 = ZERO, + v_456 = ZERO, + + source_value = false :: any, + } + + local output = create_source_node(false :: any) + + local function updater_effect() + local value = source() + data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value) + data.source_value = value + springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs + return value + end + + local updater = create_node(owner, updater_effect, false :: any) + + evaluate_node(updater) + + -- set initial position to goal + data.x0_123, data.x0_456 = data.x1_123, data.x1_456 + + -- set output to goal + output.cache = data.source_value + + return function(...) + if select("#", ...) == 0 then -- no args were given + push_child_to_scope(output) + return output.cache + end + + -- set current position to value + local v = ... :: T + data.x0_123, data.x0_456 = type_to_vec6[typeof(v)](v) + + -- reset velocity + data.v_123 = ZERO + data.v_456 = ZERO + + -- schedule spring + springs[data] = output + + -- set output to value + output.cache = v + + return v + end +end + +local function step_springs(dt: number) + for data in next, springs do + local k, c, + x0_123, x1_123, u_123, + x0_456, x1_456, u_456 = + data.k, data.c, + data.x0_123, data.x1_123, data.v_123, + data.x0_456, data.x1_456, data.v_456 + + -- calculate displacement from target + local dx_123 = x0_123 - x1_123 + local dx_456 = x0_456 - x1_456 + + -- calculate spring force + local fs_123 = dx_123*-k + local fs_456 = dx_456*-k + + -- calculate friction force + local ff_123 = u_123*-c + local ff_456 = u_456*-c + + -- calculate acceleration step + local dv_123 = (fs_123 + ff_123)*dt + local dv_456 = (fs_456 + ff_456)*dt + + -- apply acceleration step + local v_123 = u_123 + dv_123 + local v_456 = u_456 + dv_456 + + -- calculate new position + local x_123 = x0_123 + v_123*dt + local x_456 = x0_456 + v_456*dt + + data.x0_123, data.x0_456 = x_123, x_456 + data.v_123, data.v_456 = v_123, v_456 + end +end + +local remove_queue = {} + +local function update_spring_sources() + for data, output in next, springs do + local x0_123, x1_123, v_123, + x0_456, x1_456, v_456 = + data.x0_123, data.x1_123, data.v_123, + data.x0_456, data.x1_456, data.v_456 + + local dx_123, dx_456 = + x0_123 - x1_123, + x0_456 - x1_456 + + -- todo: can this false positive? + if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then + -- close enough to target, unshedule spring and set value to target + table.insert(remove_queue, data) + output.cache = data.source_value + else + output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) + end + + update_descendants(output) + end + + for _, data in next, remove_queue do + springs[data] = nil + end + + table.clear(remove_queue) +end + +return function() + local time_elapsed = 0 + + return spring, function(dt: number) + time_elapsed += dt + + while time_elapsed > 1 / UPDATE_RATE do + time_elapsed -= 1 / UPDATE_RATE + step_springs(1 / UPDATE_RATE) + end + + update_spring_sources() + end +end diff --git a/modules/Jabby/vide/switch.luau b/modules/Jabby/vide/switch.luau new file mode 100644 index 0000000..99edd3c --- /dev/null +++ b/modules/Jabby/vide/switch.luau @@ -0,0 +1,65 @@ +if not game then script = require "test/relative-string" end + +local throw = require(script.Parent.throw) +local graph = require(script.Parent.graph) +type Node = graph.Node +type SourceNode = graph.SourceNode +local create_node = graph.create_node +local evaluate_node = graph.evaluate_node +local push_child_to_scope = graph.push_child_to_scope +local destroy = graph.destroy +local assert_stable_scope = graph.assert_stable_scope +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope + +type Map = { [K]: V } + +local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? + local owner = assert_stable_scope() + + return function(map) + local last_scope: Node? + local last_component: (() -> U)? + + local function update(cached): U? + local component = map[source()] + if component == last_component then return cached end + last_component = component + + if last_scope then + destroy(last_scope :: Node) + last_scope = nil + end + + if component == nil then return nil end + + if type(component) ~= "function" then + throw "map must map a value to a function" + end + + local new_scope = create_node(owner, false, false) + last_scope = new_scope :: Node + + push_scope(new_scope) + + local ok, result = pcall(component) + + pop_scope() + + if not ok then error(result, 0) end + + return result + end + + local node = create_node(owner, update, nil) + + evaluate_node(node) + + return function() + push_child_to_scope(node) + return node.cache + end + end +end + +return switch diff --git a/modules/Jabby/vide/throw.luau b/modules/Jabby/vide/throw.luau new file mode 100644 index 0000000..954f3e2 --- /dev/null +++ b/modules/Jabby/vide/throw.luau @@ -0,0 +1,7 @@ +if not game then script = require "test/relative-string" end + +local function VIDE_ASSERT(msg): any + error(msg, 0) +end + +return VIDE_ASSERT diff --git a/modules/Jabby/vide/untrack.luau b/modules/Jabby/vide/untrack.luau new file mode 100644 index 0000000..86cdb7b --- /dev/null +++ b/modules/Jabby/vide/untrack.luau @@ -0,0 +1,27 @@ +if not game then script = require "test/relative-string" end + +local graph = require(script.Parent.graph) +type Node = graph.Node +local get_scope = graph.get_scope + +local function untrack(source: () -> T): T + local scope = get_scope() + + if scope then + -- sources are only tracked if the node in scope has an effect + local effect = scope.effect + scope.effect = false + + local ok, result = pcall(source) + + scope.effect = effect :: () -> () + + if not ok then error(result, 0) end + + return result + else + return source() + end +end + +return untrack