diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cbf1801 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,46 @@ +{ + "Lua.diagnostics.disable": [ + "unused-local", + "lowercase-global", + "unused-function" + ], + "Lua.diagnostics.severity": { + "redefined-local": "Warning" + }, + "Lua.diagnostics.globals": [ + "printError", + "sleep", + "read", + "write", + "print", + "colours", + "colors", + "commands", + "disk", + "fs", + "gps", + "help", + "http", + "paintutils", + "parallel", + "peripheral", + "rednet", + "redstone", + "keys", + "settings", + "shell", + "multishell", + "term", + "textutils", + "turtle", + "pocket", + "vector", + "bit32", + "window", + "_CC_DEFAULT_SETTINGS", + "_HOST", + "_VERSION", + "_" + ], + "Lua.runtime.version": "Lua 5.1", +} \ No newline at end of file diff --git a/components/BigText.lua b/components/BigText.lua new file mode 100644 index 0000000..4d28aba --- /dev/null +++ b/components/BigText.lua @@ -0,0 +1,31 @@ +local Solyd = require("modules.solyd") +local hooks = require("modules.hooks") +local useCanvas = hooks.useCanvas + +local bigFont = require("fonts.bigfont") + +return Solyd.wrapComponent("BigText", function(props) + local fw = props.width or bigFont:getWidth(props.text)+2 + local bgHeight = 6 + local canvas = useCanvas(fw, bigFont.height+bgHeight)--Solyd.useContext("canvas") + + Solyd.useEffect(function() + if props.bg then + for x = 1, fw do + for y = 1, bigFont.height+bgHeight do + canvas:setPixel(x, y, props.bg) + end + end + end + + local cx = props.width and math.floor((props.width - bigFont:getWidth(props.text)) / 2) or 0 + bigFont:write(canvas, props.text, 2 + cx, bgHeight-3, props.color or colors.white) + + return function() + canvas:markRect(1, 1, fw, bigFont.height+bgHeight) + end + end, { canvas, props.text, props.color, props.bg, fw }) + + local x = props.right and props.x-canvas.width+1 or props.x + return nil, { canvas = { canvas, x, props.y } } +end) diff --git a/components/Button.lua b/components/Button.lua new file mode 100644 index 0000000..c015012 --- /dev/null +++ b/components/Button.lua @@ -0,0 +1,23 @@ +local Solyd = require("modules.solyd") +local hooks = require("modules.hooks") +local useBoundingBox = hooks.useBoundingBox + +local BigText = require("components.BigText") +local bigFont = require("fonts.bigfont") + +return Solyd.wrapComponent("Button", function(props) + -- local canvas = Solyd.useContext("canvas") + -- local canvas = useCanvas() + + return BigText { + text = props.text, + x = props.x, + y = props.y, + bg = props.bg, + color = props.color, + width = props.width, + }, { + -- canvas = canvas, + aabb = useBoundingBox(props.x, props.y, props.width or bigFont:getWidth(props.text), bigFont.height+3, props.onClick), + } +end) diff --git a/components/Flex.lua b/components/Flex.lua new file mode 100644 index 0000000..808ed37 --- /dev/null +++ b/components/Flex.lua @@ -0,0 +1,37 @@ +local _ = require("util.score") +local Solyd = require("modules.solyd") + +---@param props { x: integer, y: integer, width: integer, children: SolydElement[] } +return Solyd.wrapComponent("Flex", function(props) + local remainingWidth = props.width + + local children, flexElCount, flexCount = {}, 0, 0 + for i, child in ipairs(props.children) do + if type(child) == "table" then + if child.props.width then + table.insert(children, { element = child, width = child.props.width }) + remainingWidth = remainingWidth - child.props.width - (i > 1 and 1 or 0) + else + local flex = child.props.flex or 1 + table.insert(children, { element = child, flex = flex }) + flexCount = flexCount + flex + flexElCount = flexElCount + 1 + end + end + end + + local x = props.x + local flexWidth = math.ceil((remainingWidth - flexElCount) / flexCount) + + remainingWidth = props.width + for i, child in ipairs(children) do + local width = math.min(remainingWidth, child.width or flexWidth*child.flex) + child.element.props.width = width + child.element.props.x = x + child.element.props.y = props.y + x = x + width + 1 + remainingWidth = remainingWidth - width - (i > 1 and 1 or 0) + end + + return _.map(children, function(el) return el.element end) +end) diff --git a/components/RenderCanvas.lua b/components/RenderCanvas.lua new file mode 100644 index 0000000..fe3fb90 --- /dev/null +++ b/components/RenderCanvas.lua @@ -0,0 +1,16 @@ +local Solyd = require("modules.solyd") + +local Util = require("util.misc") + +---@param props { canvas: PixelCanvas, x: integer, y: integer, remap: table } +return Solyd.wrapComponent("RenderCanvas", function(props) + local remapped = Solyd.useMemo(function() + if props.remap then + return props.canvas:clone():mapColors(props.remap) + else + return props.canvas + end + end, {props.canvas, props.remap}) + + return {}, { canvas = { remapped, props.x, props.y } } +end) diff --git a/components/Sprite.lua b/components/Sprite.lua new file mode 100644 index 0000000..d02f273 --- /dev/null +++ b/components/Sprite.lua @@ -0,0 +1,15 @@ +local Solyd = require("modules.solyd") + +return Solyd.wrapComponent("Sprite", function(props) + local canvas = Solyd.useContext("canvas")[1] + + Solyd.useEffect(function() + -- local s, x, y = props.sprite, props.x, props.y + canvas:drawCanvas(props.sprite, props.x, props.y, props.remapFrom, props.remapTo) + -- canvas:drawCanvasRotated(props.sprite, props.x, props.y, props.angle or 0) + return function() + canvas:markCanvas(props.sprite, props.x, props.y) + -- canvas:markCanvasRotated(props.sprite, props.x, props.y, props.angle or 0) + end + end, { props.sprite, props.x, props.y, props.remapFrom, props.remapTo }) +end) diff --git a/config.lua b/config.lua new file mode 100644 index 0000000..131eee0 --- /dev/null +++ b/config.lua @@ -0,0 +1,39 @@ +return { + branding = { + title = "Radon Shop" + }, + theme = { + bgColor = colors.gray, + headerBgColor = colors.red, + headerColor = colors.white + }, + currencies = { + { + id = "krist", -- if not krist or tenebra, must supply endpoint + -- endpoint = "https://krist.dev" + host = "kristallie", + name = "radon.kst", + pkey = "", + pkeyFormat = "raw", -- Either 'raw' or 'kristwallet', defaults to 'raw' + -- NOTE: It is not recommended to use kwallet, the best practice is to convert your pkey (using + -- kwallet format) to raw pkey yourself first, and then use that here. Thus improving security. + value = 1.0 -- Default scaling on item prices, can be overridden on a per-item basis + }, + { + id = "tenebra", -- if not krist or tenebra, must supply endpoint + -- endpoint = "https://krist.dev" + host = "tttttttttt", + name = "radon.tst", + pkey = "", + pkeyFormat = "raw", -- Either 'raw' or 'kristwallet', defaults to 'raw' + -- NOTE: It is not recommended to use kwallet, the best practice is to convert your pkey (using + -- kwallet format) to raw pkey yourself first, and then use that here. Thus improving security. + value = 0.1 -- Default scaling on item prices, can be overridden on a per-item basis + }, + }, + peripherals = { + monitor = nil, + exchangeChest = nil, + outputChest = nil, + } +} \ No newline at end of file diff --git a/core/ConfigValidator.lua b/core/ConfigValidator.lua new file mode 100644 index 0000000..9966b98 --- /dev/null +++ b/core/ConfigValidator.lua @@ -0,0 +1,162 @@ +local r2l = require("modules.regex") + +local configSchema = { + branding = { + title = "string" + }, + theme = { + bgColor = "color", + headerBgColor = "color", + headerColor = "color" + }, + currencies = { + __type = "array", + __min = 1, + __entry = { + id = "string", + endpoint = "string?", + host = [[regex<^\w{10}$>: address]], + name = "string", + pkey = "string", + pkeyFormat = "enum<'raw' | 'kristwallet'>: pkey format", + value = "number?" + } + }, + peripherals = { + monitor = "string?", + exchangeChest = "string?", + outputChest = "string?", + } +} + +local productsSchema = { + __type = "array", + __entry = { + modid = "string", + name = "string?", + address = "string", + order = "number?", + price = "number", + priceOverrides = { + __type = "array?", + __entry = { + currency = "string", + price = "number" + } + }, + predicate = "table?" + } +} + +local function validate(config, schema, path) + if not path then + path = "" + end + if schema.__type then + if schema.__type:sub(1, 5) == "array" then + if schema.__type:sub(6, 6) == "?" and config == nil then + return + end + if type(config) ~= "table" then + error("Config value " .. path .. " must be an array") + end + if schema.__min and #config < schema.__min then + error("Config value " .. path .. " must have at least " .. schema.__min .. " entries") + end + if schema.__max and #config > schema.__max then + error("Config value " .. path .. " must have at most " .. schema.__max .. " entries") + end + if schema.__entry then + for i = 1, #config do + validate(config[i], schema.__entry, path .. "[" .. i .. "]") + end + end + end + else + for k,v in pairs(schema) do + subpath = path .. "." .. k + if type(v) == "table" then + validate(config[k], v, subpath) + else + -- If regex or enum, get type name + -- E.g. regex<\w{10}>: address -> address + local typeDef, typeName + _, _, typeDef, typeName = v:find("^(%w+<.+>%??): (.+)$") + if typeDef then + v = typeDef + end + if v:sub(-1) ~= "?" and config[k] == nil then + error("Missing required config value: " .. subpath) + end + if v:sub(-1) == "?" then + v = v:sub(1, -2) + end + if config[k] then + if v == "table" and type(config[k]) ~= "table" then + error("Config value " .. subpath .. " must be a table") + end + if v == "string" and type(config[k]) ~= "string" then + error("Config value " .. subpath .. " must be a string") + end + if v == "number" and type(config[k]) ~= "number" then + error("Config value " .. subpath .. " must be a number") + end + if v == "color" then + if type(config[k]) ~= "number" then + error("Config value " .. subpath .. " must be a color") + end + m,n = math.frexp(config[k]) + if m ~= 0.5 or n < 1 or n > 16 then + error("Config value " .. subpath .. " must be a color") + end + end + if v == "boolean" and type(config[k]) ~= "boolean" then + error("Config value " .. subpath .. " must be a boolean") + end + if v:sub(1, 5) == "enum<" and v:sub(-1) == ">" then + local enum = v:sub(6, -2) + local found = false + for enumValue in enum:gmatch("[^|]+") do + enumValue = enumValue:sub(enumValue:find("'(.*)'")):sub(2, -2) + if config[k] == enumValue then + found = true + break + end + end + if not found then + if typeName then + error("Config value " .. subpath .. " must be type " .. typeName .. " matching " .. enum) + else + error("Config value " .. subpath .. " must be one of " .. enum) + end + end + end + if v:sub(1, 6) == "regex<" and v:sub(-1) == ">" then + local regexString = v:sub(7, -2) + local regex = r2l.new(regexString) + if not regex(config[k]) then + if typeName then + error("Config value " .. subpath .. " must be type " .. typeName .. " matching " .. regexString) + else + error("Config value " .. subpath .. " must match " .. regexString) + end + end + end + end + end + end + end +end + +local function validateConfig(config) + validate(config, configSchema, "config") +end + +local function validateProducts(products) + validate(products, productsSchema, "products") +end + +return { + validateConfig = validateConfig, + validateProducts = validateProducts +} \ No newline at end of file diff --git a/core/ShopRunner.lua b/core/ShopRunner.lua new file mode 100644 index 0000000..178001e --- /dev/null +++ b/core/ShopRunner.lua @@ -0,0 +1,74 @@ +--local ShopState = require("core.ShopState") + +local Animations = require("modules.hooks.animation") + +local function areAnimationsFinished(uid) + local finished = Animations.animationFinished[uid] + if finished then + Animations.animationFinished[uid] = nil + return true + end + + return false +end + +local function launchShop(shopState, mainFunction) + --local shopCoroutine = coroutine.create(function() ShopState.runGame(shopState) end) + local mainCoroutine = coroutine.create(mainFunction) + + local stateFilter ---@type "animationFinished" | "waitForPlayerInput" + local uidFilter + + local eventFilter + local eventBacklog = {} + + while true do + local e = (eventFilter == nil and #eventBacklog > 0) and table.remove(eventBacklog, 1) or { os.pullEvent() } + + if eventFilter and e[1] ~= eventFilter then + eventBacklog[#eventBacklog+1] = e + else + local status, result = coroutine.resume(mainCoroutine, unpack(e)) + eventFilter = result + if not status then + error(result) + end + end + + if coroutine.status(mainCoroutine) == "dead" then + break + end + + local canResume = true -- coroutine.status(gameCoroutine) ~= "dead" + if stateFilter == "animationFinished" then + canResume = areAnimationsFinished(uidFilter) + elseif stateFilter == "waitForPlayerInput" then + --canResume = isPlayerInputReady() + elseif stateFilter == "timer" then + canResume = e[1] == "timer" and e[2] == uidFilter + end + + if canResume then + -- print("resuming...") + --status, stateFilter, uidFilter = coroutine.resume(shopCoroutine) + status = "alive" + if stateFilter then + print("new filter:", stateFilter) + end + + if not status then + error(stateFilter) + end + + --[[if coroutine.status(shopCoroutine) == "dead" then + -- TODO: Reset game state + -- gameCoroutine = coroutine.create(GameState.runGame) + -- error("oops") + end]] + end + end +end + +return { + launchShop = launchShop, +} diff --git a/fonts/bigfont.lua b/fonts/bigfont.lua new file mode 100644 index 0000000..be7a67a --- /dev/null +++ b/fonts/bigfont.lua @@ -0,0 +1,7 @@ +local loadRIF = require("modules.rif") +local createFont = require("modules.font") + +local bigFontSheet = loadRIF("res/cfont.rif") +local bigFont = createFont(bigFontSheet, " abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/-,.\164!:\6") + +return bigFont diff --git a/modules/animation/Ease.lua b/modules/animation/Ease.lua new file mode 100644 index 0000000..d8200ec --- /dev/null +++ b/modules/animation/Ease.lua @@ -0,0 +1,115 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local Ease = {} + +function Ease.linear(t, b, c, d) + return c * t / d + b +end + +function Ease.inQuad(t, b, c, d) + t = t / d + return c * t * t + b +end + +function Ease.outQuad(t, b, c, d) + t = t / d + return -c * t * (t - 2) + b +end + +function Ease.inOutQuad(t, b, c, d) + t = t / d * 2 + if t < 1 then + return c / 2 * t * t + b + else + t = t - 1 + return -c / 2 * (t * (t - 2) - 1) + b + end +end + +function Ease.outInQuad(t, b, c, d) + if t < d / 2 then + return Ease.outQuad(t * 2, b, c / 2, d) + else + return Ease.inQuad((t * 2) - d, b + c / 2, c / 2, d) + end +end + +function Ease.inCubic(t, b, c, d) + t = t / d + return c * t * t * t + b +end + +function Ease.outCubic(t, b, c, d) + t = t / d - 1 + return c * (t * t * t + 1) + b +end + +function Ease.inOutCubic(t, b, c, d) + t = t / d * 2 + if t < 1 then + return c / 2 * t * t * t + b + else + t = t - 2 + return c / 2 * (t * t * t + 2) + b + end +end + +function Ease.outInCubic(t, b, c, d) + if t < d / 2 then + return Ease.outCubic(t * 2, b, c / 2, d) + else + return Ease.inCubic((t * 2) - d, b + c / 2, c / 2, d) + end +end + +function Ease.inQuart(t, b, c, d) + t = t / d + return c * t * t * t * t + b +end + +function Ease.outQuart(t, b, c, d) + t = t / d - 1 + return -c * (t * t * t * t - 1) + b +end + +function Ease.inOutQuart(t, b, c, d) + t = t / d * 2 + if t < 1 then + return c / 2 * t * t * t * t + b + else + t = t - 2 + return -c / 2 * (t * t * t * t - 2) + b + end +end + +function Ease.outInQuart(t, b, c, d) + if t < d / 2 then + return Ease.outQuart(t * 2, b, c / 2, d) + else + return Ease.inQuart((t * 2) - d, b + c / 2, c / 2, d) + end +end + +return Ease diff --git a/modules/animation/init.lua b/modules/animation/init.lua new file mode 100644 index 0000000..b618839 --- /dev/null +++ b/modules/animation/init.lua @@ -0,0 +1,145 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local Iter = require("util.iter") +local list = Iter.list + +local Ease = require("modules.animation.Ease") + +local function copy(t) + local new = {} + for k, v in pairs(t) do + new[k] = v + end + return new +end + +---@alias Step { duration: number, to: table?, easing: function | table | nil } + +---@class AnimationDescriptor +---@field sprite PixelCanvas +---@field initial table +---@field steps Step[]? + +---@alias AnimationSets AnimationDescriptor[][] + +---Evaluate one animation descriptor and return current animation state. +---@param animation AnimationDescriptor +---@param t number +---@return table animationState, number consumedTime, boolean isFinished +local function evaluateSingleAnimation(animation, t) + local state = copy(animation.initial) + state.sprite = animation.sprite + + local steps = animation.steps + if not steps then + return state, 0, true + end + + local stepIndex = 1 + local consumedTime = 0 + while t > 0 do + local step = steps[stepIndex] + if not step then break end + + if t > step.duration then -- If this step is already completed, just skip it and set the to values + if step.to then + for k, v in pairs(step.to) do + state[k] = v + end + end + + t = t - step.duration + consumedTime = consumedTime + step.duration + stepIndex = stepIndex + 1 + else + -- If this step is not completed, interpolate the values + if step.to then + local easingFunction = step.easing or Ease.linear + for k, v in pairs(step.to) do + if type(easingFunction) == "function" then + state[k] = easingFunction(t, state[k], v - state[k], step.duration) + else ---@cast easingFunction table + state[k] = (easingFunction[k] or Ease.linear)(t, state[k], v - state[k], step.duration) + end + end + end + + consumedTime = consumedTime + t + break + end + end + + return state, consumedTime, stepIndex > #steps +end + +---Evaluate all animation descriptors for iterative sets and return current animation state. +---@param animationSets AnimationSets +---@param t number +---@return { sprite: PixelCanvas, [string]: number }[] visibleSprites, boolean isFinished +local function evaluateAnimationSets(animationSets, t) + local remainingTime = t + local setContents = {} + for animationSet in list(animationSets) do + local setDuration = 0 + local allFinished = true + setContents = {} + for i = 1, #animationSet do + local animationState, consumedTime, finished = evaluateSingleAnimation(animationSet[i], remainingTime) + setContents[i] = animationState + + print("c", consumedTime, finished) + setDuration = math.max(setDuration, consumedTime) + if not finished then + allFinished = false + end + end + + if allFinished then + remainingTime = remainingTime - setDuration + else + return setContents, false -- If we have no time left, return the current set + end + end + + return setContents, true -- All sets are finished +end + +---@param animationSets AnimationSets +---@return { sprite: PixelCanvas, [string]: number }[] visibleSprites +local function skipAnimation(animationSets) + local setContents = {} + local animationSet = animationSets[#animationSets] + for i = 1, #animationSet do + setContents[i] = evaluateSingleAnimation(animationSet[i], math.huge) + end + + return setContents +end + +return { + evaluateSingleAnimation = evaluateSingleAnimation, + evaluateAnimationSets = evaluateAnimationSets, + skipAnimation = skipAnimation, +} diff --git a/modules/canvas.lua b/modules/canvas.lua new file mode 100644 index 0000000..7a56486 --- /dev/null +++ b/modules/canvas.lua @@ -0,0 +1,715 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local floor, ceil, min, max, abs, concat = math.floor, math.ceil, math.min, math.max, math.abs, table.concat +local function round(x) return floor(x + 0.5) end + +local _ = require("util.score") + +---@alias Color +---| 1 +---| 2 +---| 4 +---| 8 +---| 16 +---| 32 +---| 64 +---| 128 +---| 256 +---| 512 +---| 1024 +---| 2048 +---| 4096 +---| 8192 +---| 16384 +---| 32768 + +local _ttxChars = setmetatable({}, {__index = error}) +for i = 0, 31 do + _ttxChars[i+1] = string.char(128 + i) +end + +local _hex = setmetatable({}, {__index = error}) +for i = 0, 15 do + _hex[2^i] = string.format("%x", i) +end + +---@alias Terminal table A computercraft terminal object + +---@class PixelCanvas +---@field width integer +---@field height integer +---@field canvas { [integer]: { [integer]: integer } } +---@field dirty { [integer]: { [integer]: boolean } } +---@field allDirty boolean? +---@operator call:PixelCanvas +local PixelCanvas = {} +local PixelCanvas_mt = { __index = PixelCanvas } +setmetatable(PixelCanvas, { __call = function(_, ...) return PixelCanvas.new(...) end }) + +function PixelCanvas.new(width, height) + local self = setmetatable({__opaque = true}, PixelCanvas_mt) + self.width = width + self.height = height + self.canvas = {} + for y = 1, height do + self.canvas[y] = {} + for x = 1, width do + self.canvas[y][x] = nil + end + end + + self.dirty = {} -- { [y] = { [x] = true } } + + return self +end + +---Set the pixel at {x}, {y} to {c}. +---@param x integer +---@param y integer +---@param c Color +function PixelCanvas:setPixel(x, y, c) + x, y = floor(x), floor(y) + -- TODO: remove bounds checking? + if x < 1 or x > self.width or y < 1 or y > self.height then + return + end + + if self.canvas[y][x] ~= c then + self.canvas[y][x] = c + self.dirty[y] = self.dirty[y] or {} + self.dirty[y][x] = true + end +end + +function PixelCanvas:clone() + local clone = PixelCanvas.new(self.width, self.height) + for y = 1, self.height do + clone.dirty[y] = {} + for x = 1, self.width do + clone.canvas[y][x] = self.canvas[y][x] + clone.dirty[y][x] = true + end + end + + return clone +end + +function PixelCanvas:mapColors(map) + for y = 1, self.height do + for x = 1, self.width do + self.canvas[y][x] = map[self.canvas[y][x]] or self.canvas[y][x] + end + end + + return self +end + +---Copies the contents of {canvas} into this canvas at {x, y}. +---@param canvas PixelCanvas +---@param x integer +---@param y integer +function PixelCanvas:drawCanvas(canvas, x, y, remapFrom, remapTo) + for cy = 1, canvas.height do + for cx = 1, canvas.width do + local c = canvas.canvas[cy][cx] + if c == remapFrom then + if type(remapTo) == "table" then error() end + c = remapTo + end + + if c then -- TODO: document this + self:setPixel(x + cx - 1, y + cy - 1, c) + end + end + end +end + +function PixelCanvas:drawCanvas180(canvas, x, y) + for cy = 1, canvas.height do + for cx = 1, canvas.width do + self:setPixel(x + cx - 1, y + canvas.height - cy - 1, canvas.canvas[cy][cx]) + end + end +end + +function PixelCanvas:drawCanvasRotated(canvas, cx, cy, angle) + local sin, cos = math.sin(angle), math.cos(angle) + local absSin, absCos = math.abs(sin), math.abs(cos) + local newWidth, newHeight = ceil(canvas.width * absCos + canvas.height * absSin), ceil(canvas.width * absSin + canvas.height * absCos) + + for y = 1, newHeight do + for x = 1, newWidth do + local px, py = x - newWidth / 2, y - newHeight / 2 + local rx, ry = px * cos - py * sin, px * sin + py * cos + rx, ry = rx + canvas.width / 2, ry + canvas.height / 2 + rx, ry = round(rx), round(ry) + if rx >= 1 and rx <= canvas.width and ry >= 1 and ry <= canvas.height then + self:setPixel(x + cx - newWidth / 2, y + cy - newHeight / 2, canvas.canvas[ry][rx]) + end + end + end +end + +---Copies the contents of {canvas} into this canvas at {x, y} with color {c}. +---@param canvas PixelCanvas +---@param x integer +---@param y integer +---@param c integer +function PixelCanvas:drawTint(canvas, x, y, c) + for cy = 1, canvas.height do + for cx = 1, canvas.width do + if canvas.canvas[cy][cx] then + self:setPixel(x + cx - 1, y + cy - 1, c) + end + end + end +end + +---Copies the contents of {canvas} into this canvas at {x, y} with the specified bounds. +---@param canvas PixelCanvas +---@param x integer +---@param y integer +---@param startX integer +---@param startY integer +---@param endX integer +---@param endY integer +function PixelCanvas:drawCanvasClip(canvas, x, y, startX, startY, endX, endY) + for cy = startY, endY do + for cx = startX, endX do + self:setPixel(x + cx - startX, y + cy - startY, canvas.canvas[cy][cx]) + end + end +end + +function PixelCanvas:drawRect(c, x, y, w, h) + for cy = 1, h do + for cx = 1, w do + self:setPixel(x + cx - 1, y + cy - 1, c) + end + end +end + +---Mark the pixel at {x, y} as dirty, indicating that it should be updated during frame composition. +---@param x integer +---@param y integer +function PixelCanvas:mark(x, y) + x, y = floor(x), floor(y) + -- TODO: remove bounds checking? + if x < 1 or x > self.width or y < 1 or y > self.height then + return + end + + self.dirty[y] = self.dirty[y] or {} + self.dirty[y][x] = true +end + +---@param x integer +---@param y integer +---@param width integer +---@param height integer +function PixelCanvas:markRect(x, y, width, height) + x, y = floor(x), floor(y) + -- TODO: remove bounds checking? + + for cy = y, y + height - 1 do + if cy < 1 or cy > self.height then + -- out of bounds + else + self.dirty[cy] = self.dirty[cy] or {} + for cx = x, x + width - 1 do + if cx < 1 or cx > self.width then + -- out of bounds + else + self.canvas[cy][cx] = nil + self.dirty[cy][cx] = true + end + end + end + end +end + +---@param x integer +---@param y integer +---@param width integer +---@param height integer +function PixelCanvas:dirtyRect(x, y, width, height) + x, y = floor(x), floor(y) + -- TODO: remove bounds checking? + + for cy = y, y + height - 1 do + if cy < 1 or cy > self.height then + -- out of bounds + else + self.dirty[cy] = self.dirty[cy] or {} + for cx = x, x + width - 1 do + if cx < 1 or cx > self.width then + -- out of bounds + else + self.dirty[cy][cx] = true + end + end + end + end +end + +---Mark the rectangle formed by the given canvas at {x, y} as dirty. +---@param canvas PixelCanvas +---@param x integer +---@param y integer +function PixelCanvas:markCanvas(canvas, x, y) + for cy = 1, canvas.height do + for cx = 1, canvas.width do + self:setPixel(x + cx - 1, y + cy - 1, nil) + end + end +end + +function PixelCanvas:markCanvasRotated(canvas, cx, cy, angle) + local sin, cos = math.sin(angle), math.cos(angle) + local absSin, absCos = math.abs(sin), math.abs(cos) + local newWidth, newHeight = ceil(canvas.width * absCos + canvas.height * absSin), ceil(canvas.width * absSin + canvas.height * absCos) + + for y = 1, newHeight do + for x = 1, newWidth do + local px, py = x - newWidth / 2, y - newHeight / 2 + local rx, ry = px * cos - py * sin, px * sin + py * cos + rx, ry = rx + canvas.width / 2, ry + canvas.height / 2 + rx, ry = round(rx), round(ry) + if rx >= 1 and rx <= canvas.width and ry >= 1 and ry <= canvas.height then + self:setPixel(x + cx - newWidth / 2, y + cy - newHeight / 2, nil) + end + end + end +end + +---Creates a new PixelCanvas with the same width and height as this canvas. +---@return PixelCanvas +function PixelCanvas:newFromSize() + return PixelCanvas.new(self.width, self.height) +end + +function PixelCanvas.is(obj) + return getmetatable(obj) == PixelCanvas_mt +end + +---@param others { [1]: PixelCanvas, [2]: integer, [3]: integer }[] +function PixelCanvas:composite(others) + for _, other in ipairs(others) do + self:drawCanvas(other[1], other[2], other[3]) + end +end + +---@class TextCanvas +---@field width number +---@field height number +---@field canvas { [integer]: { t: { [integer]: string }, c: { [integer]: string }, b: { [integer]: string } } } +---@field dirty { [integer]: { [integer]: boolean } } +---@operator call:PixelCanvas +local TextCanvas = {} +local TextCanvas_mt = { __index = TextCanvas } +setmetatable(TextCanvas, { __call = function(_, ...) return TextCanvas.new(...) end }) + +function TextCanvas.new(width, height) + local self = setmetatable({__opaque = true}, TextCanvas_mt) + self.width = width + self.height = height + + self.canvas = {} + for y = 1, height do + self.canvas[y] = {} + self.canvas[y].t = {} + self.canvas[y].c = {} + self.canvas[y].b = {} + -- for x = 1, width do + -- [x] = nil + -- end + end + + self.dirty = {} -- { [y] = { [x] = true } } + + return self +end + +---Write a string to the canvas at {x, y}. +---@param text string +---@param x integer +---@param y integer +---@param c integer +---@param b integer +function TextCanvas:write(text, x, y, c, b) + x, y = floor(x), floor(y) + if x < 1 or x > self.width or y < 1 or y > self.height then + return + end + + c = _hex[c] + b = _hex[b] + + self.dirty[y] = self.dirty[y] or {} + for i = 1, #text do + if x + i - 1 > self.width then + break + end + + self.dirty[y][x + i - 1] = true + self.canvas[y].t[x + i - 1] = text:sub(i, i) + self.canvas[y].c[x + i - 1] = c + self.canvas[y].b[x + i - 1] = b + end +end + +---Mark a string to the canvas at {x, y}. +---@param text string +---@param x integer +---@param y integer +function TextCanvas:markText(text, x, y) + x, y = floor(x), floor(y) + if x < 1 or x > self.width or y < 1 or y > self.height then + return + end + + self.dirty[y] = self.dirty[y] or {} + for i = 1, #text do + if x + i - 1 > self.width then + break + end + + self.dirty[y][x + i - 1] = true + self.canvas[y].t[x + i - 1] = nil + end +end + +function TextCanvas.is(self) + return getmetatable(self) == TextCanvas_mt +end + +---@class TeletextCanvas +---@field width integer +---@field height integer +---@field clear integer The color to use when a pixel is transparent. +---@field pixelCanvas PixelCanvas +---@field canvas { [integer]: { t: { [integer]: string }, c: { [integer]: string }, b: { [integer]: string }, direct: { [integer]: boolean } } } +---@field dirty { [integer]: { [integer]: boolean } } +---@field dirtyRows { [integer]: { [1]: integer, [2]: integer } } +---@operator call:TeletextCanvas +local TeletextCanvas = {} +local TeletextCanvas_mt = { __index = TeletextCanvas } +setmetatable(TeletextCanvas, { __call = function(_, ...) return TeletextCanvas.new(...) end }) + +function TeletextCanvas.new(clear, width, height) + local self = setmetatable({__opaque = true}, TeletextCanvas_mt) + self.width = width + self.height = height + self.clear = clear or colors.black + + self.pixelCanvas = PixelCanvas(width*2, height*3) + + self.canvas = {} + for y = 1, height do + self.canvas[y] = { t = {}, c = {}, b = {}, direct = {} } + for x = 1, width do + self.canvas[y].t[x] = " " + self.canvas[y].c[x] = "0" + self.canvas[y].b[x] = _hex[clear] + self.canvas[y].direct[x] = false + end + end + + self.dirty = {} -- { [y] = { [x] = true } } + self.dirtyRows = {} + + return self +end + +---Composites the given pixel canvases onto the teletext's internal +---pixel canvas and recomputes the teletext's character data. +---@param ... { [1]: PixelCanvas, [2]: integer, [3]: integer } +function TeletextCanvas:composite(...) + local others = {...} + + -- Partition the screen based on canvas x/y/w/h + -- local t1 = os.epoch("utc") + local partitionSize = 20 + local partitions = {} + for y = 1, self.height*3, partitionSize do + partitions[floor(y/partitionSize)+1] = {} + for x = 1, self.width*2, partitionSize do + partitions[floor(y/partitionSize)+1][floor(x/partitionSize)+1] = {} + end + end + -- local t2 = os.epoch("utc") + -- print("Partitioning took " .. (t2-t1) .. "ms") + + -- t1 = os.epoch("utc") + for _, other in ipairs(others) do + other[2] = floor(other[2]) + other[3] = floor(other[3]) + -- if PixelCanvas.is(other) then + local otherCanvas, otherX, otherY = other[1], other[2], other[3] + + local originPartitionX = floor(otherX/partitionSize)*partitionSize+1 + local originPartitionY = floor(otherY/partitionSize)*partitionSize+1 + + for y = originPartitionY, otherY+otherCanvas.height-1, partitionSize do + for x = originPartitionX, otherX+otherCanvas.width-1, partitionSize do + local px = floor(x/partitionSize)+1 + local py = floor(y/partitionSize)+1 + local partition = (partitions[py] or {})[px] + if partition then + partition[#partition + 1] = other + end + end + end + -- end + end + -- t2 = os.epoch("utc") + -- print("Canvas partition assignment took " .. (t2-t1) .. "ms") + + -- t1 = os.epoch("utc") + local queuedDirty = {} + local c = 0 + for _, other in ipairs(others) do + -- local isPixel = PixelCanvas.is(other) + local ocanvas, ox, oy = other[1], other[2]-1, other[3]-1 + if ocanvas.allDirty then + for y = 1, ocanvas.height do + for x = 1, ocanvas.width do + local tx = x+ox + local ty = y+oy + if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then + queuedDirty[ty] = queuedDirty[ty] or {} + queuedDirty[ty][tx] = true + c = c + 1 + end + end + end + else + for y, row in pairs(ocanvas.dirty) do + for x, _ in pairs(row) do + -- if isPixel then + local tx = x+ox + local ty = y+oy + if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then + queuedDirty[ty] = queuedDirty[ty] or {} + queuedDirty[ty][tx] = true + c = c + 1 + end + + -- else + -- -- TODO: ewwwwwwww + -- queuedDirty[y*3] = queuedDirty[y*3] or {} + -- queuedDirty[y*3][x*2] = true + -- queuedDirty[y*3][x*2-1] = true + -- queuedDirty[y*3-1] = queuedDirty[y*3-1] or {} + -- queuedDirty[y*3-1][x*2] = true + -- queuedDirty[y*3-1][x*2-1] = true + -- queuedDirty[y*3-2] = queuedDirty[y*3-2] or {} + -- queuedDirty[y*3-2][x*2] = true + -- queuedDirty[y*3-2][x*2-1] = true + -- end + end + end + end + + ocanvas.dirty = {} -- TODO: is this necessary? + end + -- t2 = os.epoch("utc") + + -- t1 = os.epoch("utc") + for y, row in pairs(queuedDirty) do + local targetY = ceil(y / 3) + self.dirty[targetY] = self.dirty[targetY] or {} + -- print("row size: " .. #row) + for x, _ in pairs(row) do + local targetX = ceil(x / 2) + local currPixel = self.pixelCanvas.canvas[y][x] + local partition = partitions[floor(y/partitionSize)+1][floor(x/partitionSize)+1] + + local found = false + -- local foundText = false + for i = #partition, 1, -1 do + local other = partition[i] + local otherCanvas, ox, oy = other[1], other[2], other[3] + -- if PixelCanvas.is(otherCanvas) then ---@cast other PixelCanvas + -- t1 = os.epoch("utc") + if otherCanvas.canvas[y-oy+1] then + local otherPixel = (otherCanvas.canvas[y-oy+1] or {})[x-ox+1] + if otherPixel then + found = true + + if otherPixel ~= currPixel then + self.pixelCanvas.canvas[y][x] = otherPixel + self.dirty[targetY][targetX] = true + end + + break + end + end + -- t2 = os.epoch("utc") + -- else ---@cast other TextCanvas + -- local otherRow = other.canvas[targetY] + -- local otherT = otherRow.t[targetX] + -- local otherC = otherRow.c[targetX] + -- local otherB = otherRow.b[targetX] + -- if otherT then + -- found = true + -- foundText = true + + -- local currRow = self.canvas[targetY] + -- local currT = currRow.t[targetX] + -- local currC = currRow.c[targetX] + -- local currB = currRow.b[targetX] + + -- if otherT ~= currT or otherC ~= currC or otherB ~= currB then + -- currRow.t[targetX] = otherT + -- currRow.c[targetX] = otherC + -- currRow.b[targetX] = otherB + -- currRow.direct[targetX] = true + -- self.dirty[targetY][targetX] = false -- Already processed + + -- local dirtyRow = self.dirtyRows[targetY] or {} + -- local minX, maxX = dirtyRow[1], dirtyRow[2] + -- minX = minX and min(minX, targetX) or targetX + -- maxX = maxX and max(maxX, targetX) or targetX + -- self.dirtyRows[targetY] = { minX, maxX } + -- end + + -- break + -- end + -- end + end + + if not found then + self.pixelCanvas.canvas[y][x] = self.clear + self.dirty[targetY][targetX] = true + end + + -- if not foundText then + -- if self.canvas[targetY].direct[targetX] then + -- self.canvas[targetY].direct[targetX] = false + -- self.dirty[targetY][targetX] = true + -- end + -- end + end + end + -- t2 = os.epoch("utc") + -- print("Canvas merging took " .. (t2-t1) .. "ms") + + -- Recalculate teletext canvas + local clear = self.clear + for y, row in pairs(self.dirty) do + local oy = (y - 1) * 3 + + local dirtyRow = self.dirtyRows[y] or {} + local minX, maxX = dirtyRow[1], dirtyRow[2] + for x, _ in pairs(row) do + minX = minX and min(minX, x) or x + maxX = maxX and max(maxX, x) or x + + local ox = (x - 1) * 2 + local sub, char, c1, c2, c3, c4, c5, c6 = 32768, 1, + self.pixelCanvas.canvas[oy + 1][ox + 1] or clear, + self.pixelCanvas.canvas[oy + 1][ox + 2] or clear, + self.pixelCanvas.canvas[oy + 2][ox + 1] or clear, + self.pixelCanvas.canvas[oy + 2][ox + 2] or clear, + self.pixelCanvas.canvas[oy + 3][ox + 1] or clear, + self.pixelCanvas.canvas[oy + 3][ox + 2] or clear + + if c1 ~= c6 then + sub = c1 + char = 2 + end + if c2 ~= c6 then + sub = c2 + char = char + 2 + end + if c3 ~= c6 then + sub = c3 + char = char + 4 + end + if c4 ~= c6 then + sub = c4 + char = char + 8 + end + if c5 ~= c6 then + sub = c5 + char = char + 16 + end + + if self.canvas[y].direct[x] == false then + self.canvas[y].t[x] = _ttxChars[char] + self.canvas[y].c[x] = _hex[sub] + self.canvas[y].b[x] = _hex[c6] + end + end + + self.dirtyRows[y] = { minX, maxX } + end +end + +---Output any dirty rows to the given {out} terminal. +---@param out Terminal +function TeletextCanvas:outputDirty(out) + for y, row in pairs(self.dirtyRows) do + local minX, maxX = row[1], row[2] + if minX then + local t,c,b + if maxX - minX == 0 then + t = self.canvas[y].t[minX] + c = self.canvas[y].c[minX] + b = self.canvas[y].b[minX] + else + t = concat(self.canvas[y].t, "", minX, maxX) + c = concat(self.canvas[y].c, "", minX, maxX) + b = concat(self.canvas[y].b, "", minX, maxX) + end + + out.setCursorPos(minX, y) + out.blit(t, c, b) + end + end + + self.dirty = {} + self.dirtyRows = {} +end + +---Output the entire canvas to the given {out} terminal. +---@param out Terminal +function TeletextCanvas:outputFlush(out) + for y = 1, self.height do + local t = concat(self.canvas[y].t, "") + local c = concat(self.canvas[y].c, "") + local b = concat(self.canvas[y].b, "") + + out.setCursorPos(1, y) + out.blit(t, c, b) + end +end + +return { + PixelCanvas = PixelCanvas, + TeletextCanvas = TeletextCanvas, + TextCanvas = TextCanvas, +} diff --git a/modules/display.lua b/modules/display.lua new file mode 100644 index 0000000..b024f10 --- /dev/null +++ b/modules/display.lua @@ -0,0 +1,56 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local canvases = require("modules.canvas") +local PixelCanvas = canvases.PixelCanvas +local TeletextCanvas = canvases.TeletextCanvas +local TextCanvas = canvases.TextCanvas + + +local mon = peripheral.find("monitor") +if not mon then + mon = term +else + mon.setTextScale(0.5) +end + +-- Set Riko Palette +require("util.riko")(mon) + +local ccCanvas = TeletextCanvas(colors.green, mon.getSize()) +ccCanvas:outputFlush(mon) + +local bgCanvas = ccCanvas.pixelCanvas:newFromSize() +for y = 1, bgCanvas.height do + for x = 1, bgCanvas.width do + -- T-Piece + bgCanvas:setPixel(x, y, colors.lightGray) + end +end + +return { + ccCanvas = ccCanvas, + bgCanvas = bgCanvas, + mon = mon, +} diff --git a/modules/font.lua b/modules/font.lua new file mode 100644 index 0000000..b9ab2ce --- /dev/null +++ b/modules/font.lua @@ -0,0 +1,122 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local canvases = require("modules.canvas") +local PixelCanvas = canvases.PixelCanvas + +---@class Font +---@field private chars { [string]: FontChar } +---@field height number +---@field kerning number +local Font = {} +local Font_mt = { __index = Font } + +---@alias FontChar PixelCanvas + +---@param char PixelCanvas +local function fixChar(char) + for y = 1, char.height do + for x = 1, char.width do + if char.canvas[y][x] == colors.black then + char.canvas[y][x] = nil + end + end + end + + return char +end + +---@param canvas PixelCanvas +---@param text string +---@param x integer +---@param y integer +---@param c integer +function Font:write(canvas, text, x, y, c) + local cx = x + for i = 1, #text do + local char = self.chars[text:sub(i, i)] + if char then + canvas:drawTint(char, cx, y, c) + cx = cx + char.width + self.kerning + end + end +end + +---@param text string +function Font:getWidth(text) + local width = 0 + for i = 1, #text do + local char = self.chars[text:sub(i, i)] + if char then + width = width + char.width + 1 + end + end + + if width == 0 then + return 0 + end + + return width - 1 +end + +---Write right aligned +---@param canvas PixelCanvas +---@param text string +---@param x integer +---@param y integer +---@param c integer +function Font:writeRight(canvas, text, x, y, c) + local width = self:getWidth(text) + self:write(canvas, text, x - width + 1, y, c) +end + +---@param fontSheet PixelCanvas +---@param mapping string +---@return Font +return function(fontSheet, mapping) + local self = setmetatable({}, Font_mt) + + self.height = fontSheet.height-1 + self.chars = {} + self.kerning = 1 + + local charStartX = 1 + local x = 1 + for i = 1, #mapping do + local name = mapping:sub(i, i) + + repeat + x = x + 1 + local nc = fontSheet.canvas[self.height+1][x] + until x > fontSheet.width or (nc and nc ~= colors.black) + + local char = PixelCanvas(x - charStartX, self.height) + char:drawCanvasClip(fontSheet, 1, 1, charStartX, 1, x - 1, self.height) + self.chars[name] = fixChar(char) + + charStartX = x + 1 + end + + return self +end diff --git a/modules/hooks/aabb.lua b/modules/hooks/aabb.lua new file mode 100644 index 0000000..d7a4133 --- /dev/null +++ b/modules/hooks/aabb.lua @@ -0,0 +1,62 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local Solyd = require("modules.solyd") + +local function useBoundingBox(x, y, w, h, onClick) + local box = Solyd.useRef(function() + return { x = x, y = y, w = w, h = h, onClick = onClick } + end).value + + box.x = x + box.y = y + box.w = w + box.h = h + box.onClick = onClick + + return box +end + +local function findNodeAt(boxes, x, y) + -- x, y = x*2, y*3 + for i = #boxes, 1, -1 do + local box = boxes[i] + if box.__type == "list" then + local node = findNodeAt(box, x, y) + if node then + return node + end + else + if x*2 >= box.x and x*2-1 < box.x + box.w + and y*3 >= box.y and y*3-2 < box.y + box.h then + return box + end + end + end +end + +return { + useBoundingBox = useBoundingBox, + findNodeAt = findNodeAt, +} diff --git a/modules/hooks/animation.lua b/modules/hooks/animation.lua new file mode 100644 index 0000000..ce01f9f --- /dev/null +++ b/modules/hooks/animation.lua @@ -0,0 +1,65 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local Solyd = require("modules.solyd") + +local animationRequests = {} +local animationFinished = {} + +---@return number? +local function useAnimation(playing) + -- local anim = Solyd.useRef(function() + -- return { playing = playing, frame = 0, time = 0 } + -- end).value + local t, setT = Solyd.useState(0) + + if playing then + -- Request animation frame + animationRequests[#animationRequests + 1] = {t, setT} + + return t + elseif t ~= 0 then + print("reset") + setT(0) + return + else + -- print("ff", t) + end +end + +local function tickAnimations(dt) + -- Clone the queue to avoid mutating it while iterating + local animationQueue = {unpack(animationRequests)} + animationRequests = {} + for _, v in ipairs(animationQueue) do + local aT, setT = v[1], v[2] + setT(aT + dt) + end +end + +return { + useAnimation = useAnimation, + tickAnimations = tickAnimations, + animationFinished = animationFinished, +} diff --git a/modules/hooks/canvas.lua b/modules/hooks/canvas.lua new file mode 100644 index 0000000..c82fe89 --- /dev/null +++ b/modules/hooks/canvas.lua @@ -0,0 +1,42 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local Solyd = require("modules.solyd") +local canvases = require("modules.canvas") +local display = require("modules.display") + +---@return PixelCanvas +local function useCanvas(w,h) + local c = Solyd.useMemo(function() + if w then + return canvases.PixelCanvas(w, h) + else + return display.ccCanvas.pixelCanvas:newFromSize() + end + end, {w, h}) + + return c +end + +return useCanvas diff --git a/modules/hooks/init.lua b/modules/hooks/init.lua new file mode 100644 index 0000000..29e9f6f --- /dev/null +++ b/modules/hooks/init.lua @@ -0,0 +1,31 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +return { + useCanvas = require("modules.hooks.canvas"), + useBoundingBox = require("modules.hooks.aabb").useBoundingBox, + findNodeAt = require("modules.hooks.aabb").findNodeAt, + useAnimation = require("modules.hooks.animation").useAnimation, + tickAnimations = require("modules.hooks.animation").tickAnimations, +} diff --git a/modules/regex/emitter.lua b/modules/regex/emitter.lua new file mode 100644 index 0000000..2e35ac7 --- /dev/null +++ b/modules/regex/emitter.lua @@ -0,0 +1,330 @@ +--[[ +MIT License + +Copyright (c) 2019 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local util = require("modules.regex.util") + +local emitter = {} + +-- local counter = 0 +-- local function makeName() +-- counter = counter + 1 +-- return "n" .. counter +-- end + +--[[ + +local states = { + s1 = function(nextChar) ... end, + ... +} + +local acceptStates = {s1 = true, ...} + +return function match(str) + local strlen = #str + local state = s1 + local allMatches, ai = {}, 1 + + for startChar = 1, strlen do -- Conditional upon properties.clampStart + local ci = 0 + while state and ci <= strlen do + if acceptStates[state] then + if ci == strlen then -- Conditional upon properties.clampEnd + allMatches[ai] = {str:sub(1, ci), startChar, ci + startChar - 1} + end + end + + state = states[state](str:sub(ci + 1, ci + 1)) + + ci = ci + 1 + end + end + + return unpack(allMatches) +end + +]] + +local function generateFunction(state) + if #state.edges == 0 then + return "function() end" + end + + local output = "function(char)" + + local dests = {} + for i = 1, #state.edges do + local edge = state.edges[i] + local dest = edge.dest + dests[dest] = dests[dest] or {} + dests[dest][#dests[dest] + 1] = edge.condition + end + + local prefix = "if" + for dest, conds in pairs(dests) do + output = output .. "\n " .. prefix + + table.sort(conds) + local ranges = {} + local singles = {} + + while #conds > 0 do + if #conds == 1 then + singles[#singles + 1] = conds[1] + break + elseif #conds == 2 then + singles[#singles + 1] = conds[1] + singles[#singles + 1] = conds[2] + break + end + + local val, index = conds[1]:byte(), 2 + while conds[index]:byte() - index + 1 == val do + index = index + 1 + if index > #conds then + break + end + end + + index = index - 1 + + if index == 1 then + singles[#singles + 1] = table.remove(conds, 1) + elseif index == 2 then + singles[#singles + 1] = table.remove(conds, 1) + singles[#singles + 1] = table.remove(conds, 1) + else + ranges[#ranges + 1] = {string.char(val), string.char(val + index - 1)} + for i = 1, index do + table.remove(conds, 1) + end + end + end + + local first = true + + for i = 1, #ranges do + local range = ranges[i] + + if first then + first = false + else + output = output .. " or" + end + + output = output .. " (char >= " .. range[1]:byte() .. " and char <= " .. range[2]:byte() .. ")" + + -- if range[1] == "]" then + -- output = output .. "%]" + -- elseif range[1]:match("[a-zA-Z]") then + -- output = output .. range[1] + -- else + -- output = output .. "\\" .. range[1]:byte() + -- end + + -- output = output .. "-" + + -- if range[2] == "]" then + -- output = output .. "%]" + -- elseif range[2]:match("[a-zA-Z]") then + -- output = output .. range[2] + -- else + -- output = output .. "\\" .. range[2]:byte() + -- end + end + + for i = 1, #singles do + if first then + first = false + else + output = output .. " or" + end + + output = output .. " char == " .. singles[i]:byte() + -- if singles[i]:match("[a-zA-Z]") then + -- output = output .. singles[i] + -- else + -- output = output .. "%\\" .. singles[i]:byte() + -- end + end + + output = output .. " then return " .. dest + + prefix = "elseif" + end + + output = output .. " end\n end" + + return output +end + +--[[ + machine = { + states = { + [sName] = {edges = {}} + }, + startState = sName, + acceptStates = {[sName] = true} + } +]] + +local function numericize(dfa) + local newMachine = util.deepClone(dfa) + + local oldNames = {} + local newNames = {} + local counter = 0 + for k, v in pairs(dfa.states) do + counter = counter + 1 + + newMachine.states[k] = nil + newMachine.states[counter] = v + newNames[k] = counter + oldNames[counter] = k + end + + for i = 1, counter do + local oldEdges = dfa.states[oldNames[i]].edges + local newEdges = newMachine.states[i].edges + local n = #oldEdges + + for j = 1, n do + newEdges[j].dest = newNames[oldEdges[j].dest] + end + end + + newMachine.startState = newNames[dfa.startState] + for k, v in pairs(dfa.acceptStates) do + newMachine.acceptStates[k] = nil + newMachine.acceptStates[newNames[k]] = v + end + + return newMachine +end + +function emitter.generateLua(dfa) + dfa = numericize(dfa) + + local output = [[ +local unpack = unpack or table.unpack + +local states = { +]] + + for i = 1, #dfa.states do + local state = dfa.states[i] + output = output .. " " .. generateFunction(state) .. ",\n" + end + + output = output .. [[} + +local stateEntries = { +]] + +for i = 1, #dfa.states do + output = output .. " {" + + local state = dfa.states[i] + local entries = util.nub(state.enter) + for j = 1, #entries do + output = output .. "'" .. entries[j] .. "'," + end + + output = output .. "},\n" +end + + output = output .. [[} + +local acceptStates = {]] + + for state in pairs(dfa.acceptStates) do + output = output .. "[" .. state .."] = true," + end + + output = output .. [[} +return function(str) + local strlen = #str + local allMatches, ai = {}, 1 + + ]] + + if dfa.properties.clampStart then + output = output .. "local startChar = 1 do\n" + else + output = output .. "for startChar = 1, strlen do\n" + end + + output = output .. [[ + local state = ]] + + output = output .. dfa.startState + + output = output .. [[ + + local ci = startChar - 1 + while state and ci <= strlen do + if acceptStates[state] then +]] + + if dfa.properties.clampEnd then + output = output .. [[ + if ci == strlen then + ]] + else + output = output .. [[ + do + ]] + end + + output = output .. [[allMatches[ai] = {str:sub(startChar, ci), startChar, ci} + ai = ai + 1 + end + end + + local char = str:sub(ci + 1, ci + 1):byte() + if char then + state = states[state](char) + end + + ci = ci + 1 + end + end + + local result + for i = 1, #allMatches do + if (not result) or #allMatches[i][1] > #result[1] then + result = allMatches[i] + end + end + + if result then + return unpack(result) + end +end +]] + + return output +end + +return emitter diff --git a/modules/regex/init.lua b/modules/regex/init.lua new file mode 100644 index 0000000..a24b988 --- /dev/null +++ b/modules/regex/init.lua @@ -0,0 +1,47 @@ +-- Main utilty file, exposes entire library +--[[ +MIT License + +Copyright (c) 2019 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local r2l = {} + +r2l.parser = require("modules.regex.parser") + +r2l.nfactory = require("modules.regex.nfactory") + +r2l.reducer = require("modules.regex.reducer") + +r2l.emitter = require("modules.regex.emitter") + +r2l.new = function(regex) + local tokens = r2l.parser.lexRegex(regex) + local parseSuccess, parsedRegex = pcall(r2l.parser.parse, tokens) + if not parseSuccess then + error("Failed to parse regex: " .. parsedRegex) + end + local origNFA = r2l.nfactory.generateNFA(parsedRegex) + local origDFA = r2l.reducer.reduceNFA(origNFA) + return loadstring(r2l.emitter.generateLua(origDFA))() +end + +return r2l diff --git a/modules/regex/nfactory.lua b/modules/regex/nfactory.lua new file mode 100644 index 0000000..e4b41b4 --- /dev/null +++ b/modules/regex/nfactory.lua @@ -0,0 +1,349 @@ +--[[ +MIT License + +Copyright (c) 2019 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local nf = {} + +local util = require("modules.regex.util") + +nf.epsilon = {type = "epsilon"} -- Special value for epsilon transition + +local nameCounter = 0 +local function genName() + nameCounter = nameCounter + 1 + return "s" .. nameCounter +end + +local function emptyMachine(noAccept) + local sName = genName() + local machine = { + states = { + [sName] = {edges = {}} + }, + startState = sName, + acceptStates = {[sName] = true} + } + + if noAccept then + machine.acceptStates = {} + end + + return machine +end + +local function addEnter(state, value) + state.enter = state.enter or {} + state.enter[#state.enter + 1] = value +end + +function nf.semanticClone(machine) + local cmachine = util.deepClone(machine) + + -- Rename all states so there are no collisions + local suffix = genName() + cmachine.startState = cmachine.startState .. suffix + local astates = {} + for k in pairs(cmachine.acceptStates) do + astates[#astates + 1] = k + end + + for i = 1, #astates do + local k, v = astates[i], cmachine.acceptStates[astates[i]] + cmachine.acceptStates[k] = nil + cmachine.acceptStates[k .. suffix] = v + end + + local states = {} + for k in pairs(cmachine.states) do + states[#states + 1] = k + end + + for j = 1, #states do + local k, v = states[j], cmachine.states[states[j]] + + for i = 1, #v.edges do + v.edges[i].dest = v.edges[i].dest .. suffix + end + + cmachine.states[k] = nil + cmachine.states[k .. suffix] = v + end + + return cmachine +end + +function nf.concatMachines(first, second) + local newMachine = util.deepClone(first) + + for k, v in pairs(second.states) do + newMachine.states[k] = v + end + + for k in pairs(first.acceptStates) do + local xs = newMachine.states[k].edges + xs[#xs + 1] = {condition = nf.epsilon, dest = second.startState} + end + + newMachine.acceptStates = {} + for k, v in pairs(second.acceptStates) do + newMachine.acceptStates[k] = v + end + + return newMachine +end + +function nf.unionMachines(first, second) + local newMachine = util.deepClone(first) + + for k, v in pairs(second.states) do + newMachine.states[k] = v + end + + for k, v in pairs(second.acceptStates) do + newMachine.acceptStates[k] = v + end + + -- Link start state + local xs = newMachine.states[newMachine.startState].edges + xs[#xs + 1] = {condition = nf.epsilon, dest = second.startState} + + return newMachine +end + +function nf.generateFromCapture(atom) + local capture = atom[1] + + local machine + if capture.type == "char" then + local sName, cName = genName(), genName() + machine = { + states = { + [sName] = {edges = {{condition = capture.value, dest = cName}}}, + [cName] = {edges = {}} + }, + startState = sName, + acceptStates = {[cName] = true} + } + elseif capture.type == "any" then + local sName, cName = genName(), genName() + machine = { + states = { + [sName] = {edges = {}}, + [cName] = {edges = {}} + }, + startState = sName, + acceptStates = {[cName] = true} + } + + local sEdges = machine.states[sName].edges + for i = 1, 255 do + sEdges[#sEdges + 1] = {condition = string.char(i), dest = cName} + end + elseif capture.type == "set" or capture.type == "negset" then + local sName, cName = genName(), genName() + machine = { + states = { + [sName] = {edges = {}}, + [cName] = {edges = {}} + }, + startState = sName, + acceptStates = {[cName] = true} + } + + local tState = {} + for i = 1, #capture do + local match = capture[i] + if match.type == "char" then + tState[match.value] = true + elseif match.type == "range" then + local dir = match.finish:byte() - match.start:byte() + dir = dir / math.abs(dir) + + for j = match.start:byte(), match.finish:byte(), dir do + tState[string.char(j)] = true + end + end + end + + local sEdges = machine.states[sName].edges + if capture.type == "set" then + for k in pairs(tState) do + sEdges[#sEdges + 1] = {condition = k, dest = cName} + end + else + for i = 1, 255 do + if not tState[string.char(i)] then + sEdges[#sEdges + 1] = {condition = string.char(i), dest = cName} + end + end + end + elseif capture.type == "group" then + machine = nf.generateNFA(capture[1]) + local instance = genName() + addEnter(machine.states[machine.startState], "begin-group-" .. instance) + for k in pairs(machine.acceptStates) do + addEnter(machine.states[k], "end-group-" .. instance) + end + else + error("Unimplemented capture: '" .. capture.type .. "'") + end + + if atom.type == "atom" then + return machine + elseif atom.type == "plus" then + local instance = genName() + addEnter(machine.states[machine.startState], "begin-sort-" .. instance) + + for k in pairs(machine.acceptStates) do + local es = machine.states[k].edges + es[#es + 1] = {condition = nf.epsilon, dest = machine.startState} + + -- Mark the state for recording, used for path reduction later + addEnter(machine.states[k], "maximize-" .. instance) + end + + return machine + elseif atom.type == "ng-plus" then + local instance = genName() + addEnter(machine.states[machine.startState], "begin-sort-" .. instance) + + for k in pairs(machine.acceptStates) do + local es = machine.states[k].edges + es[#es + 1] = {condition = nf.epsilon, priority = "low", dest = machine.startState} + + -- Mark the state for recording + addEnter(machine.states[k], "minimize-" .. instance) + end + + return machine + elseif atom.type == "star" then + local instance = genName() + addEnter(machine.states[machine.startState], "begin-sort-" .. instance) + + local needStart = true + for k in pairs(machine.acceptStates) do + local es = machine.states[k].edges + es[#es + 1] = {condition = nf.epsilon, dest = machine.startState} + if k == machine.startState then + needStart = false + end + + -- Mark the state for recording + addEnter(machine.states[k], "maximize-" .. instance) + end + + if needStart then + machine.acceptStates[machine.startState] = true + end + + return machine + elseif atom.type == "ng-star" then + local instance = genName() + addEnter(machine.states[machine.startState], "begin-sort-" .. instance) + + local needStart = true + for k in pairs(machine.acceptStates) do + local es = machine.states[k].edges + es[#es + 1] = {condition = nf.epsilon, priority = "low", dest = machine.startState} + if k == machine.startState then + needStart = false + end + + -- Mark the state for recording + addEnter(machine.states[k], "minimize-" .. instance) + end + + if needStart then + machine.acceptStates[machine.startState] = true + end + + return machine + elseif atom.type == "optional" then + machine.acceptStates[machine.startState] = true + + return machine + elseif atom.type == "quantifier" then + local quantifier = atom.quantifier + if quantifier.type == "count" then + local single = machine + for _ = 2, quantifier.count do + machine = nf.concatMachines(single, nf.semanticClone(machine)) + end + + return machine + else -- range + local single = machine + for _ = 2, quantifier.min do + machine = nf.concatMachines(single, nf.semanticClone(machine)) + end + + if quantifier.max == math.huge then + local prevMachine = nf.semanticClone(machine) + machine = nf.concatMachines(prevMachine, util.deepClone(single)) + for k, v in pairs(prevMachine.acceptStates) do + machine.acceptStates[k] = v + end + + for k in pairs(machine.acceptStates) do + local es = machine.states[k].edges + es[#es + 1] = {condition = nf.epsilon, dest = single.startState} + end + else + -- All in this range are valid, so setup those links + for _ = quantifier.min + 1, quantifier.max do + local prevMachine = nf.semanticClone(machine) + machine = nf.concatMachines(prevMachine, util.deepClone(single)) + for k, v in pairs(prevMachine.acceptStates) do + machine.acceptStates[k] = v + end + end + end + + return machine + end + else + error("Unimplemented atom type: '" .. atom.type .. "'") + end +end + +function nf.generateNFA(parsedRegex) + local machine = emptyMachine(true) + machine.properties = parsedRegex.properties + + for i = 1, #parsedRegex do + -- Different branches + local branch = parsedRegex[i] + local tempMachine = emptyMachine() + + for j = 1, #branch do + local capture = branch[j] + tempMachine = nf.concatMachines(tempMachine, nf.generateFromCapture(capture)) + end + + machine = nf.unionMachines(machine, tempMachine) + end + + return machine +end + +return nf diff --git a/modules/regex/parser.lua b/modules/regex/parser.lua new file mode 100644 index 0000000..a4d1817 --- /dev/null +++ b/modules/regex/parser.lua @@ -0,0 +1,400 @@ +-- Regex Parser +-- Parses to an internal IL representation used for the construction of an NFA + +--[[ +MIT License + +Copyright (c) 2019 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local parser = {} + +function parser.lexRegex(regexStr) + local termEaten + local function peek() + return regexStr:sub(1, 1) + end + + local pos = 0 + local function eatc() + local c = peek() + termEaten = termEaten .. c + regexStr = regexStr:sub(2) + pos = pos + 1 + return c + end + + local switchTable = { + ["|"] = "union", + ["*"] = function() + if peek() == "?" then + eatc() + return "ng-star" + end + + return "star" + end, + ["+"] = function() + if peek() == "?" then + eatc() + return "ng-plus" + end + + return "plus" + end, + ["?"] = "optional", + ["("] = "l-paren", + [")"] = "r-paren", + ["{"] = "l-bracket", + ["}"] = "r-bracket", + ["."] = "any", + ["^"] = "start", + ["$"] = "eos", + ["\\"] = function() + local metas = {d = "[0-9]", w = "[a-zA-Z]", n = "\n"} + + local c = eatc() + if metas[c] then + + regexStr = metas[c] .. regexStr + pos = pos - #metas[c] + + return false + end + + termEaten = termEaten:sub(2) + return "char" + end, + ["["] = function() + if peek() == "^" then + eatc() + return "open-negset" + end + + return "open-set" + end, + ["]"] = "close-set", + ["-"] = "range" + } + + local tokens = {} + while #regexStr > 0 do + termEaten = "" + local c = eatc() + local lexFn = switchTable[c] + local ret = "char" + if lexFn then + if type(lexFn) == "string" then + ret = lexFn + else + ret = lexFn() + end + end + + if ret then + tokens[#tokens + 1] = { + type = ret, + source = termEaten, + position = pos + } + end + end + + tokens[#tokens + 1] = {type = "eof", source = "", position = pos + 1} + + return tokens +end + +--[[ + +Grammar: + + ::= + ::= "|" | + ::= + ::= | + ::= | | | | | + ::= "*" + ::= "+" + ::= "*?" + ::= "+?" + ::= "{" "}" + ::= "," | "," + ::= | | | | + ::= "(" ")" + ::= "." + ::= "$" + ::= any non metacharacter | "\" metacharacter + ::= | + ::= "[" "]" + ::= "[^" "]" + ::= | + ::= | + ::= "-" + +Special Chars: | * + *? +? ( ) . $ \ [ [^ ] - + +]] + +function parser.parse(tokenList) + local RE, unionList, simpleRE, basicRE, basicREList, elementaryRE, quantifier, group, set, setItems, setItem + + local parseTable = { + unionList = {["union"] = 1, default = 2}, + basicREList = {["union"] = 2, ["r-paren"] = 2, ["eof"] = 2, default = 1}, + elementaryRE = {["l-paren"] = 1, ["any"] = 2, ["char"] = 3, ["open-set"] = 4, ["open-negset"] = 4}, + setItems = {["close-set"] = 1, default = 2} + } + + local function eat() + return table.remove(tokenList, 1) + end + + local function uneat(token) + table.insert(tokenList, 1, token) + end + + local function expect(token, source) + local tok = eat() + if tok.type ~= token then + error("Unexpected token '" .. tok.type .. "' at position " .. tok.position, 0) + end + + if source and not tok.source:match(source) then + error("Unexpected '" .. tok.source .. "' at position " .. tok.position, 0) + end + + return tok + end + + local function getMyType(name, index) + local parseFn = parseTable[name][tokenList[index or 1].type] or parseTable[name].default + + if not parseFn then + error("Unexpected token '" .. tokenList[index or 1].type .. "' at position " .. tokenList[index or 1].position, 0) + end + + return parseFn + end + + local function unrollLoop(container) + local list, i = {}, 1 + + while container do + list[i], i = container[1], i + 1 + container = container[2] + end + + return unpack(list) + end + + -- ::= + function RE() + return {type = "RE", simpleRE(), unrollLoop(unionList())} + end + + -- ::= "|" | + function unionList() + local parseFn = getMyType("unionList") + + if parseFn == 1 then + eat() + return {type = "unionList", simpleRE(), unionList()} + else + return + end + end + + -- ::= + function simpleRE() + return {type = "simpleRE", basicRE(), unrollLoop(basicREList())} + end + + -- ::= | | | | | + function basicRE() + local atom = elementaryRE() + + local token = eat() + local type = token.type + if type == "star" then + return {type = "star", atom} + elseif type == "plus" then + return {type = "plus", atom} + elseif type == "ng-star" then + return {type = "ng-star", atom} + elseif type == "ng-plus" then + return {type = "ng-plus", atom} + elseif type == "optional" then + return {type = "optional", atom} + elseif type == "l-bracket" then + uneat(token) + + return {type = "quantifier", atom, quantifier = quantifier()} + else + uneat(token) + return {type = "atom", atom} + end + end + + -- ::= "{" "}" + -- ::= "," | "," + function quantifier() + expect("l-bracket") + + local firstDigit = "" + do + local nextTok = expect("char", "%d") + local src = nextTok.source + repeat + firstDigit = firstDigit .. src + + nextTok = eat() + + src = nextTok.source + until src:match("%D") + uneat(nextTok) + end + + if tokenList[1].type == "r-bracket" then + eat() + + local count = tonumber(firstDigit) + return {type = "count", count = count} + end + expect("char", ",") + + local secondDigit = "" + if tokenList[1].source:match("%d") then + local src, nextTok = "" + repeat + secondDigit = secondDigit .. src + + nextTok = eat() + + src = nextTok.source + until src:match("%D") + uneat(nextTok) + end + + expect("r-bracket") + + return {type = "range", min = tonumber(firstDigit), max = tonumber(secondDigit) or math.huge} + end + + -- ::= | + function basicREList() + local parseFn = getMyType("basicREList") + + if parseFn == 1 then + return {type = "basicREList", basicRE(), basicREList()} + else + return + end + end + + -- ::= | | | + function elementaryRE() + local parseFn = getMyType("elementaryRE") + + if parseFn == 1 then + return group() + elseif parseFn == 2 then + eat() + return {type = "any"} + elseif parseFn == 3 then + local token = eat() + return {type = "char", value = token.source} + elseif parseFn == 4 then + return set() + end + end + + -- ::= "(" ")" + function group() + eat() + local rexp = RE() + eat() + + return {type = "group", rexp} + end + + -- ::= | + function set() + local openToken = eat() + + local ret + if openToken.type == "open-set" then + ret = {type = "set", unrollLoop(setItems())} + else -- open-negset + ret = {type = "negset", unrollLoop(setItems())} + end + + eat() + + return ret + end + + -- ::= | + function setItems() + local firstItem = setItem() + local parseFn = getMyType("setItems") + + if parseFn == 1 then + return {type = "setItems", firstItem} + else + return {type = "setItems", firstItem, setItems()} + end + end + + -- ::= | + function setItem() + if tokenList[2].type == "range" then + return {type = "range", start = eat().source, finish = (eat() and eat()).source} + else + return {type = "char", value = eat().source} + end + end + + local props = { + clampStart = false, + clampEnd = false + } + + if tokenList[1].type == "start" then + props.clampStart = true + table.remove(tokenList, 1) + end + + if tokenList[#tokenList - 1].type == "eos" then + props.clampEnd = true + table.remove(tokenList, #tokenList - 1) + end + + if #tokenList == 1 then + error("Empty regex", 0) + end + + local ret = RE() + ret.properties = props + return ret +end + +return parser diff --git a/modules/regex/pprint.lua b/modules/regex/pprint.lua new file mode 100644 index 0000000..b69fef2 --- /dev/null +++ b/modules/regex/pprint.lua @@ -0,0 +1,476 @@ +-- luacheck: ignore + +local pprint = { VERSION = '0.1' } + +local depth = 1 + +pprint.defaults = { + -- If set to number N, then limit table recursion to N deep. + depth_limit = false, + -- type display trigger, hide not useful datatypes by default + -- custom types are treated as table + show_nil = true, + show_boolean = true, + show_number = true, + show_string = true, + show_table = true, + show_function = false, + show_thread = false, + show_userdata = false, + -- additional display trigger + show_metatable = false, -- show metatable + show_all = false, -- override other show settings and show everything + use_tostring = false, -- use __tostring to print table if available + filter_function = nil, -- called like callback(value[,key, parent]), return truty value to hide + object_cache = 'local', -- cache blob and table to give it a id, 'local' cache per print, 'global' cache + -- per process, falsy value to disable (might cause infinite loop) + -- format settings + indent_size = 2, -- indent for each nested table level + level_width = 80, -- max width per indent level + wrap_string = true, -- wrap string when it's longer than level_width + wrap_array = false, -- wrap every array elements + sort_keys = true, -- sort table keys +} + +local TYPES = { + ['nil'] = 1, ['boolean'] = 2, ['number'] = 3, ['string'] = 4, + ['table'] = 5, ['function'] = 6, ['thread'] = 7, ['userdata'] = 8 +} + +-- seems this is the only way to escape these, as lua don't know how to map char '\a' to 'a' +local ESCAPE_MAP = { + ['\a'] = '\\a', ['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r', + ['\t'] = '\\t', ['\v'] = '\\v', ['\\'] = '\\\\', +} + +-- generic utilities +local function escape(s) + s = s:gsub('([%c\\])', ESCAPE_MAP) + local dq = s:find('"') + local sq = s:find("'") + if dq and sq then + return s:gsub('"', '\\"'), '"' + elseif sq then + return s, '"' + else + return s, "'" + end +end + +local function is_plain_key(key) + return type(key) == 'string' and key:match('^[%a_][%a%d_]*$') +end + +local CACHE_TYPES = { + ['table'] = true, ['function'] = true, ['thread'] = true, ['userdata'] = true +} + +-- cache would be populated to be like: +-- { +-- function = { `fun1` = 1, _cnt = 1 }, -- object id +-- table = { `table1` = 1, `table2` = 2, _cnt = 2 }, +-- visited_tables = { `table1` = 7, `table2` = 8 }, -- visit count +-- } +-- use weakrefs to avoid accidentall adding refcount +local function cache_apperance(obj, cache, option) + if not cache.visited_tables then + cache.visited_tables = setmetatable({}, {__mode = 'k'}) + end + local t = type(obj) + + -- TODO can't test filter_function here as we don't have the ix and key, + -- might cause different results? + -- respect show_xxx and filter_function to be consistent with print results + if (not TYPES[t] and not option.show_table) + or (TYPES[t] and not option['show_'..t]) then + return + end + + if CACHE_TYPES[t] or TYPES[t] == nil then + if not cache[t] then + cache[t] = setmetatable({}, {__mode = 'k'}) + cache[t]._cnt = 0 + end + if not cache[t][obj] then + cache[t]._cnt = cache[t]._cnt + 1 + cache[t][obj] = cache[t]._cnt + end + end + if t == 'table' or TYPES[t] == nil then + if cache.visited_tables[obj] == false then + -- already printed, no need to mark this and its children anymore + return + elseif cache.visited_tables[obj] == nil then + cache.visited_tables[obj] = 1 + else + -- visited already, increment and continue + cache.visited_tables[obj] = cache.visited_tables[obj] + 1 + return + end + for k, v in pairs(obj) do + cache_apperance(k, cache, option) + cache_apperance(v, cache, option) + end + local mt = getmetatable(obj) + if mt and option.show_metatable then + cache_apperance(mt, cache, option) + end + end +end + +-- makes 'foo2' < 'foo100000'. string.sub makes substring anyway, no need to use index based method +local function str_natural_cmp(lhs, rhs) + while #lhs > 0 and #rhs > 0 do + local lmid, lend = lhs:find('%d+') + local rmid, rend = rhs:find('%d+') + if not (lmid and rmid) then return lhs < rhs end + + local lsub = lhs:sub(1, lmid-1) + local rsub = rhs:sub(1, rmid-1) + if lsub ~= rsub then + return lsub < rsub + end + + local lnum = tonumber(lhs:sub(lmid, lend)) + local rnum = tonumber(rhs:sub(rmid, rend)) + if lnum ~= rnum then + return lnum < rnum + end + + lhs = lhs:sub(lend+1) + rhs = rhs:sub(rend+1) + end + return lhs < rhs +end + +local function cmp(lhs, rhs) + local tleft = type(lhs) + local tright = type(rhs) + if tleft == 'number' and tright == 'number' then return lhs < rhs end + if tleft == 'string' and tright == 'string' then return str_natural_cmp(lhs, rhs) end + if tleft == tright then return str_natural_cmp(tostring(lhs), tostring(rhs)) end + + -- allow custom types + local oleft = TYPES[tleft] or 9 + local oright = TYPES[tright] or 9 + return oleft < oright +end + +-- setup option with default +local function make_option(option) + if option == nil then + option = {} + end + for k, v in pairs(pprint.defaults) do + if option[k] == nil then + option[k] = v + end + if option.show_all then + for t, _ in pairs(TYPES) do + option['show_'..t] = true + end + option.show_metatable = true + end + end + return option +end + +-- override defaults and take effects for all following calls +function pprint.setup(option) + pprint.defaults = make_option(option) +end + +-- format lua object into a string +function pprint.pformat(obj, option, printer) + option = make_option(option) + local buf = {} + local function default_printer(s) + table.insert(buf, s) + end + printer = printer or default_printer + + local cache + if option.object_cache == 'global' then + -- steal the cache into a local var so it's not visible from _G or anywhere + -- still can't avoid user explicitly referentce pprint._cache but it shouldn't happen anyway + cache = pprint._cache or {} + pprint._cache = nil + elseif option.object_cache == 'local' then + cache = {} + end + + local last = '' -- used for look back and remove trailing comma + local status = { + indent = '', -- current indent + len = 0, -- current line length + } + + local wrapped_printer = function(s) + printer(last) + last = s + end + + local function _indent(d) + status.indent = string.rep(' ', d + #(status.indent)) + end + + local function _n(d) + wrapped_printer('\n') + wrapped_printer(status.indent) + if d then + _indent(d) + end + status.len = 0 + return true -- used to close bracket correctly + end + + local function _p(s, nowrap) + status.len = status.len + #s + if not nowrap and status.len > option.level_width then + _n() + wrapped_printer(s) + status.len = #s + else + wrapped_printer(s) + end + end + + local formatter = {} + local function format(v) + local f = formatter[type(v)] + f = f or formatter.table -- allow patched type() + if option.filter_function and option.filter_function(v, nil, nil) then + return '' + else + return f(v) + end + end + + local function tostring_formatter(v) + return tostring(v) + end + + local function number_formatter(n) + return n == math.huge and '[[math.huge]]' or tostring(n) + end + + local function nop_formatter(v) + return '' + end + + local function make_fixed_formatter(t, has_cache) + if has_cache then + return function (v) + return string.format('[[%s %d]]', t, cache[t][v]) + end + else + return function (v) + return '[['..t..']]' + end + end + end + + local function string_formatter(s, force_long_quote) + local s, quote = escape(s) + local quote_len = force_long_quote and 4 or 2 + if quote_len + #s + status.len > option.level_width then + _n() + -- only wrap string when is longer than level_width + if option.wrap_string and #s + quote_len > option.level_width then + -- keep the quotes together + _p('[[') + while #s + status.len >= option.level_width do + local seg = option.level_width - status.len + _p(string.sub(s, 1, seg), true) + _n() + s = string.sub(s, seg+1) + end + _p(s) -- print the remaining parts + return ']]' + end + end + + return force_long_quote and '[['..s..']]' or quote..s..quote + end + + local function table_formatter(t) + if option.use_tostring then + local mt = getmetatable(t) + if mt and mt.__tostring then + return string_formatter(tostring(t), true) + end + end + + local print_header_ix = nil + local ttype = type(t) + if option.object_cache then + local cache_state = cache.visited_tables[t] + local tix = cache[ttype][t] + -- FIXME should really handle `cache_state == nil` + -- as user might add things through filter_function + if cache_state == false then + -- already printed, just print the the number + return string_formatter(string.format('%s %d', ttype, tix), true) + elseif cache_state > 1 then + -- appeared more than once, print table header with number + print_header_ix = tix + cache.visited_tables[t] = false + else + -- appeared exactly once, print like a normal table + end + end + + local limit = tonumber(option.depth_limit) + if limit and depth > limit then + if print_header_ix then + return string.format('[[%s %d]]...', ttype, print_header_ix) + end + return string_formatter(tostring(t), true) + end + + local tlen = #t + local wrapped = false + _p('{') + _indent(option.indent_size) + _p(string.rep(' ', option.indent_size - 1)) + if print_header_ix then + _p(string.format('--[[%s %d]] ', ttype, print_header_ix)) + end + for ix = 1,tlen do + local v = t[ix] + if formatter[type(v)] == nop_formatter or + (option.filter_function and option.filter_function(v, ix, t)) then + -- pass + else + if option.wrap_array then + wrapped = _n() + end + depth = depth+1 + _p(format(v)..', ') + depth = depth-1 + end + end + + -- hashmap part of the table, in contrast to array part + local function is_hash_key(k) + if type(k) ~= 'number' then + return true + end + + local numkey = math.floor(tonumber(k)) + if numkey ~= k or numkey > tlen or numkey <= 0 then + return true + end + end + + local function print_kv(k, v, t) + -- can't use option.show_x as obj may contain custom type + if formatter[type(v)] == nop_formatter or + formatter[type(k)] == nop_formatter or + (option.filter_function and option.filter_function(v, k, t)) then + return + end + wrapped = _n() + if is_plain_key(k) then + _p(k, true) + else + _p('[') + -- [[]] type string in key is illegal, needs to add spaces inbetween + local k = format(k) + if string.match(k, '%[%[') then + _p(' '..k..' ', true) + else + _p(k, true) + end + _p(']') + end + _p(' = ', true) + depth = depth+1 + _p(format(v), true) + depth = depth-1 + _p(',', true) + end + + if option.sort_keys then + local keys = {} + for k, _ in pairs(t) do + if is_hash_key(k) then + table.insert(keys, k) + end + end + table.sort(keys, cmp) + for _, k in ipairs(keys) do + print_kv(k, t[k], t) + end + else + for k, v in pairs(t) do + if is_hash_key(k) then + print_kv(k, v, t) + end + end + end + + if option.show_metatable then + local mt = getmetatable(t) + if mt then + print_kv('__metatable', mt, t) + end + end + + _indent(-option.indent_size) + -- make { } into {} + last = string.gsub(last, '^ +$', '') + -- peek last to remove trailing comma + last = string.gsub(last, ',%s*$', ' ') + if wrapped then + _n() + end + _p('}') + + return '' + end + + -- set formatters + formatter['nil'] = option.show_nil and tostring_formatter or nop_formatter + formatter['boolean'] = option.show_boolean and tostring_formatter or nop_formatter + formatter['number'] = option.show_number and number_formatter or nop_formatter -- need to handle math.huge + formatter['function'] = option.show_function and make_fixed_formatter('function', option.object_cache) or nop_formatter + formatter['thread'] = option.show_thread and make_fixed_formatter('thread', option.object_cache) or nop_formatter + formatter['userdata'] = option.show_userdata and make_fixed_formatter('userdata', option.object_cache) or nop_formatter + formatter['string'] = option.show_string and string_formatter or nop_formatter + formatter['table'] = option.show_table and table_formatter or nop_formatter + + if option.object_cache then + -- needs to visit the table before start printing + cache_apperance(obj, cache, option) + end + + _p(format(obj)) + printer(last) -- close the buffered one + + -- put cache back if global + if option.object_cache == 'global' then + pprint._cache = cache + end + + return table.concat(buf) +end + +-- pprint all the arguments +function pprint.pprint( ... ) + local args = {...} + -- select will get an accurate count of array len, counting trailing nils + local len = select('#', ...) + for ix = 1,len do + pprint.pformat(args[ix], nil, io.write) + io.write('\n') + end +end + +setmetatable(pprint, { + __call = function (_, ...) + pprint.pprint(...) + end +}) + +return pprint diff --git a/modules/regex/reducer.lua b/modules/regex/reducer.lua new file mode 100644 index 0000000..997a9ac --- /dev/null +++ b/modules/regex/reducer.lua @@ -0,0 +1,169 @@ +--[[ +MIT License + +Copyright (c) 2019 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local dr = {} + +local pprint = require("modules.regex.pprint") +local util = require("modules.regex.util") + +local function isEpsilon(condition) + return type(condition) == "table" and condition.type == "epsilon" +end + +local function traverseEpsilon(machine, state, seen) + seen = seen or {} + + local func = {state} + + local edges = machine.states[state].edges + for i = 1, #edges do + local edge = edges[i] + if isEpsilon(edge.condition) then + if not seen[edge.dest] then + seen[edge.dest] = true + + -- Epsilon transition, add to E function + func[#func + 1] = edge.dest + + -- Traverse through its destination and fully evaluate the epsilon path + local extraFn = traverseEpsilon(machine, edge.dest, seen) + + -- Append those new states to this E function + local pos = #func + for j = 1, #extraFn do + func[pos + 1] = extraFn[j] + pos = pos + 1 + end + end + end + end + + return util.nub(func) +end + +local function nameFromST(st) + table.sort(st) + return table.concat(st) +end + +function dr.reduceNFA(nfa) + -- Construct E function + local eFunc = {} + for k in pairs(nfa.states) do + local eI = 1 + eFunc[k] = traverseEpsilon(nfa, k) + end + + local newMachine = { + states = {}, + startState = nameFromST(eFunc[nfa.startState]), + acceptStates = {}, + properties = nfa.properties + } + + local todoStates = {eFunc[nfa.startState]} + local completeStates = {} + + while todoStates[1] do -- while todoStates is not empty + local workingState = table.remove(todoStates, 1) + local newState = { + edges = {}, + enter = {} + } + + local lang = {} + + local isAccepted = false + + -- Get all possible inputs by traversing states + for i = 1, #workingState do + local stateName = workingState[i] + if nfa.acceptStates[stateName] then + isAccepted = true + end + + local state = nfa.states[stateName] + for j = 1, #state.edges do + local cond = state.edges[j].condition + if type(cond) == "string" then + lang[cond] = lang[cond] or {} + lang[cond][#lang[cond] + 1] = state.edges[j].dest + end + end + end + + -- For each possible input, compute the resultant state, and create an edge + for k, v in pairs(lang) do + local st, si = {}, 1 + + for i = 1, #v do + local throughput = eFunc[v[i]] + for j = 1, #throughput do + st[si] = throughput[j] + si = si + 1 + end + end + + st = util.nub(st) + table.sort(st) + + local destState = nameFromST(st) + newState.edges[#newState.edges + 1] = { + condition = k, + dest = destState + } + + if not completeStates[destState] then + todoStates[#todoStates + 1] = st + completeStates[destState] = true + end + end + + -- Append each enter condition + local ei = 1 + for i = 1, #workingState do + local stateName = workingState[i] + local state = nfa.states[stateName] + + if state.enter then + for j = 1, #state.enter do + newState.enter[ei] = state.enter[j] + ei = ei + 1 + end + end + end + + local stateName = nameFromST(workingState) + newMachine.states[stateName] = newState + if isAccepted then + newMachine.acceptStates[stateName] = true + end + + completeStates[stateName] = true + end + + return newMachine +end + +return dr diff --git a/modules/regex/util.lua b/modules/regex/util.lua new file mode 100644 index 0000000..325248e --- /dev/null +++ b/modules/regex/util.lua @@ -0,0 +1,32 @@ +local util = {} + +function util.deepClone(tab) + local nt = {} + + for k, v in pairs(tab) do + if type(v) == "table" then + nt[k] = util.deepClone(v) + else + nt[k] = v + end + end + + return nt +end + +function util.nub(tab) + local entries = {} + local nt, i = {}, 1 + + for k, v in pairs(tab) do + if not entries[v] then + entries[v] = true + nt[i] = v + i = i + 1 + end + end + + return nt +end + +return util diff --git a/modules/rif.lua b/modules/rif.lua new file mode 100644 index 0000000..51c0ba0 --- /dev/null +++ b/modules/rif.lua @@ -0,0 +1,118 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local canvases = require("modules.canvas") +local PixelCanvas = canvases.PixelCanvas + +-- local palMap = { +-- colors.black, +-- colors.blue, +-- colors.purple, +-- colors.green, +-- colors.brown, +-- colors.gray, +-- colors.lightGray, +-- colors.red, +-- colors.orange, +-- colors.yellow, +-- colors.lime, +-- colors.cyan, +-- colors.magenta, +-- colors.pink, +-- colors.darkGreen, +-- colors.white +-- } + +local revPalMap = { + "black", + "blue", + "purple", + "green", + "brown", + "gray", + "lightGray", + "red", + "orange", + "yellow", + "lime", + "cyan", + "magenta", + "pink", + "darkGreen", + "white" +} + +local palMap = {} +for i = 1, 16 do + palMap[i] = 2^(i - 1) + colors[revPalMap[i]] = 2^(i - 1) +end + +return function(filename) + -- Riko 4 image format + + local file = fs.open(filename, "rb") + local data = file.readAll() + + local width, height = data:byte(5) * 256 + data:byte(6), data:byte(7) * 256 + data:byte(8) + local canv = PixelCanvas(width, height) + local buffer = canv.canvas + + for i = 1, math.ceil(width * height / 2) do + local byte = data:byte(19 + i) + local fp = bit.brshift(bit.band(byte, 240), 4) + local sp = bit.band(byte, 15) + + -- 1, 3, 5 + local pix = i * 2 - 1 + local x = (pix - 1) % width + 1 + local y = math.ceil(pix / width) + buffer[y][x] = palMap[fp + 1] + local x2 = pix % width + 1 + local y2 = math.ceil((pix + 1) / width) + if buffer[y2] then + buffer[y2][x2] = palMap[sp + 1] + end + end + + if data:byte(9) == 1 then + -- Transparency map + local max = 19 + math.ceil(width * height / 2) + for i = 1, math.ceil(width * height / 8) do + local byte = data:byte(max + i) + for j = 1, 8 do + local pix = (i - 1) * 8 + j + local x = (pix - 1) % width + 1 + local y = math.ceil(pix / width) + if bit.band(byte, 2 ^ (j - 1)) ~= 0 then + if buffer[y] then + buffer[y][x] = nil + end + end + end + end + end + + return canv +end diff --git a/modules/solyd.lua b/modules/solyd.lua new file mode 100644 index 0000000..2f24bec --- /dev/null +++ b/modules/solyd.lua @@ -0,0 +1,553 @@ +--[[ +MIT License + +Copyright (c) 2022 emmachase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local _ = require("util.score") + +local Solyd = {} -- like lyqyd but not + +local __hook +local function getKey() + local key = __hook.__volatile.key + if key == nil then + key = 1 + __hook.__volatile.key = 2 + else + __hook.__volatile.key = key + 1 + end + + return key +end + +---Helper definition for the lazy variant of useState, the type system isn't stronk enough otherwise +---@alias UseStateFn fun(initial: fun(): T): T, fun(newValue: T): T +---@alias UseState fun(initial: T): T, fun(newValue: T): T + +local setChange = false + +---Use State as a hook, sets the initial value if it is not set. +---@generic T +---@param initial T +---@return T, fun(value: T): T +function Solyd.useState(initial) + local key = getKey() + + local state = __hook[key] + if state == nil then + state = { value = type(initial) == "function" and initial() or initial } + __hook[key] = state + end + + state.dirty = false + + local function setState(newState) + state.value = newState + state.dirty = true + setChange = true + return newState + end + + return state.value, setState +end + +---Returns returns a mutable ref object whose .value property is initialized to the passed argument {initial}. +---The returned object will persist for the full lifetime of the component. +---@generic T +---@param initial fun(): T +---@return { value: T } +function Solyd.useRef(initial) + local key = getKey() + + local ref = __hook[key] + if ref == nil then + ref = { value = initial() } + __hook[key] = ref + end + + return ref +end + +---Pass a “create” function and an array of dependencies. useMemo will only recompute the memoized +---value when one of the dependencies has changed. This optimization helps to avoid expensive +---calculations on every render. +---@generic T +---@param fun fun(): T +---@param deps any[]? +---@return T +function Solyd.useMemo(fun, deps) + local key = getKey() + + local memo = __hook[key] + if memo == nil then + memo = { value = fun() } + __hook[key] = memo + end + + if memo.deps == nil then + memo.deps = deps + else + for i, v in ipairs(deps) do + if v ~= memo.deps[i] then + memo.value = fun() + memo.deps = deps + break + end + end + end + + return memo.value +end + +function Solyd.useCallback(fun, deps) + return Solyd.useMemo(function() return fun end, deps) +end + +---Accepts a function that contains imperative, possibly effectful code. +---The function passed to useEffect may return a clean-up function +---@param fun fun(): fun()? +---@param deps any[]? +function Solyd.useEffect(fun, deps) + local key = getKey() + + local memo = __hook[key] + if memo == nil then + memo = { unmount = fun() } + __hook[key] = memo + end + + if memo.deps == nil then + memo.deps = deps + else + for i, v in ipairs(deps) do + if v ~= memo.deps[i] then + memo.unmount() + memo.deps = deps + memo.unmount = fun() + + break + end + end + end +end + +---Get a value from the component context using the given key, returns nil if the key is not found. +---@generic T +---@param key any +---@return T? +function Solyd.useContext(key) + local parentHookCtx = __hook.__volatile.parentContext + while parentHookCtx do + if parentHookCtx.context and parentHookCtx.context[key] then + parentHookCtx.contextConsumers[key] = parentHookCtx.contextConsumers[key] or {} + for i = 1, #parentHookCtx.contextConsumers[key] do + if parentHookCtx.contextConsumers[key][i] == __hook then + return parentHookCtx.context[key] + end + end + + table.insert(parentHookCtx.contextConsumers[key], __hook) + __hook.contextSubscriptions = __hook.contextSubscriptions or {} + __hook.contextSubscriptions[key] = parentHookCtx.contextConsumers + return parentHookCtx.context[key] + end + + parentHookCtx = parentHookCtx.__volatile and parentHookCtx.__volatile.parentContext + end + + return nil +end + +---Gets the topologically ordered list of context values that were assigned with the given keys. +---@param tree table? +---@param keys string[] +---@return table +function Solyd.getTopologicalContext(tree, keys) + local values = {} + for i, key in ipairs(keys) do + values[key] = {} + end + + if not tree then + return values + end + + local queue = {tree} + while #queue > 0 do + local node = table.remove(queue, 1) + + if node.context then + for i, key in ipairs(keys) do + if node.context[key] then + table.insert(values[key], node.context[key]) + end + end + end + + if node.dom then + if node.dom.src and node.dom.src.__tag == "element" then + table.insert(queue, 1, node.dom) + else + for i = #node.dom, 1, -1 do + if type(node.dom[i]) == "table" and node.dom[i].src then + assert(node.dom[i].src.__tag == "element", "Invalid tree") + table.insert(queue, 1, node.dom[i]) + end + end + end + end + end + + return values +end + +---@alias ElementType "element" +---@alias SolydElement { __tag: ElementType, props: table, propsDiff: table, key: any?, component: fun(props: table): SolydElement | SolydElement[] } + +---Create a Solyd element from a comonent and props. +---@generic P: table +---@param component fun(P): table The component function +---@param props P +---@param key any? +---@return SolydElement +function Solyd.createElement(name, component, props, key) + return { __tag="element", name = name, component = component, props = props, propsDiff = _.copyDeep(props), key = key } +end + +local function propsChanged(oldProps, newProps) + if type(newProps) ~= "table" or newProps.__opaque ~= nil then + return oldProps ~= newProps + end + + if type(oldProps) ~= type(newProps) then + return true + end + + local keySet = {} + for k, v in pairs(newProps) do + keySet[k] = true + if type(v) == "table" and v.__opaque == nil then + if propsChanged(oldProps[k], v) then + return true + end + elseif oldProps[k] ~= v then + return true + end + end + + for k, v in pairs(oldProps) do + if not keySet[k] then + return true + end + end + + return false +end + +-- local function tableSize(t) +-- if type(t) ~= "table" then +-- return nil +-- end + +-- local count = 0 +-- for k, v in pairs(t) do +-- count = count + 1 +-- end +-- return count +-- end + +---Call any unmount functions bottom-up to ensure everything is cleaned up. +local function _unmount(node) + if node.dom then + if node.dom.src and node.dom.src.__tag == "element" then + _unmount(node.dom) + else + for i = 1, #node.dom do + _unmount(node.dom[i]) + end + end + end + + if node.src and node.src.component then + for k, v in pairs(node.hook) do + if type(v) == "table" and v.unmount then + v.unmount() + end + end + + if node.hook.contextSubscriptions then + for key, consumers in pairs(node.hook.contextSubscriptions) do + for i = 1, #consumers do + if consumers[i] == node.hook then + table.remove(consumers, i) + break + end + end + end + end + end +end + +---Renders a Solyd element via tree expansion, pass the return value to the next invocation. +---@param previousTree table? +---@param rootComponent SolydElement? +---@return table? +local function _render(previousTree, rootComponent, parentContext, forceRender) + local nextTree = previousTree + + if type(rootComponent) ~= "table" then + return { src = nil } + end + + if forceRender + or not previousTree + or propsChanged(previousTree.src.propsDiff, rootComponent.propsDiff) + -- or propsChanged(previousTree.hook.__volatile.parentContext, parentContext) + then + -- upkeep + local hook = previousTree and previousTree.hook or { contextConsumers = {} } + hook.__volatile = { + __opaque = true, + previousTree = previousTree, + rootComponent = rootComponent, + parentContext = parentContext or {} + } + + __hook = hook + + -- update + local newTree, context = rootComponent.component(rootComponent.props) + + if context and context.gameState then + -- print(hook.contextDiff, context.gameState, propsChanged(hook.contextDiff, context)) + end + + -- TODO: Optimize only call context consumers for the context that changed + if propsChanged(hook.contextDiff, context) then + for k, v in pairs(hook.contextConsumers or {}) do + for i = 1, #v do + v[i].contextDirty = { dirty = true } + end + end + + hook.contextDiff = _.copyDeep(context) + end + + hook.context = context + -- hook.contextConsumers = {} + + if not newTree then + -- Check if we need to unmount children + if previousTree and previousTree.dom and previousTree.dom.src then + if previousTree.dom.src.__tag == "element" then + _unmount(previousTree.dom) + else + for i = 1, #previousTree.dom do + _unmount(previousTree.dom[i]) + end + end + end + + nextTree = { src = rootComponent, context = context, hook = hook } + else + -- TODO: verif ythat support swapping between singel and multiple children returned + if newTree.__tag == "element" and ((not previousTree) or previousTree.dom.src ~= nil) then + local oldChild = previousTree and previousTree.dom + if oldChild and oldChild.src.component == newTree.component then + nextTree = { src = rootComponent, hook = hook, context = context, dom = _render(oldChild, newTree, hook) } + else + if oldChild then + _unmount(previousTree) -- component changed, unmount old tree + end + nextTree = { src = rootComponent, hook = hook, context = context, dom = _render(nil, newTree, hook) } + end + else + local oldChildren = previousTree and previousTree.dom ---@type table? + -- this supports the swapping behavior + if oldChildren and oldChildren.src ~= nil then + oldChildren = { oldChildren } + end + if newTree.__tag == "element" then + newTree = { newTree } + end + + local children = {} + + local previousUniqueComponents = previousTree and previousTree.keyed or {} + + local nextUniqueComponents, nIndex = {}, 0 + for i, child in ipairs(newTree) do + if type(child) == "table" and child.key then + local prevMatch = previousUniqueComponents[child.key] + if prevMatch and prevMatch.src and prevMatch.src.component ~= child.component then + prevMatch = nil + end + + local newChild = _render(prevMatch, child, hook) + nextUniqueComponents[child.key] = newChild + children[i] = newChild + elseif type(child) == "table" then + nIndex = nIndex + 1 + local prevMatch = previousUniqueComponents[nIndex] + if prevMatch and prevMatch.src and prevMatch.src.component ~= child.component then + prevMatch = nil + end + + local newChild = _render(prevMatch, child, hook) + nextUniqueComponents[nIndex] = newChild + children[i] = newChild + else + -- should not mount + nIndex = nIndex + 1 + children[i] = { src = nil } + end + end + + -- Find any children that no longer exist in the new tree + for key, child in pairs(previousUniqueComponents) do + if child and not nextUniqueComponents[key] or nextUniqueComponents[key].src.component ~= child.src.component then + _unmount(child) + end + end + + nextTree = { src = rootComponent, hook = hook, context = context, dom = children, keyed = nextUniqueComponents } + end + end + + hook.__volatile.nextTree = nextTree + end + + return nextTree +end + +---Traverses the tree to find dirty nodes and re-renders them, updating them in-place. +---@param tree table? +---@return table? +local function _cleanDirty(tree) + local queue = {{nil, tree, nil}} + local queuedMounts = {} + while #queue > 0 do + local didUpdate = false + local pair = table.remove(queue, 1) + local parent, node = pair[1], pair[2] + local originalNode = node + if node.hook then + local dirty = false + for k, v in pairs(node.hook) do + if type(v) == "table" and v.dirty then + dirty = true + v.dirty = false + end + end + + if dirty then + local volatile = node.hook.__volatile + node = _render(volatile.nextTree, volatile.rootComponent, volatile.parentContext, true) + didUpdate = true + end + end + + if didUpdate then + table.insert(queuedMounts, {parent, node, originalNode}) + end + + if node and node.dom then + if node.dom.src and node.dom.src.__tag == "element" then + table.insert(queue, {node, node.dom, parent}) + else + for i = 1, #node.dom do + table.insert(queue, {node, node.dom[i], parent}) + end + end + end + end + + for i = 1, #queuedMounts do + local triple = queuedMounts[i] + local parent, node, originalNode = triple[1], triple[2], triple[3] + + if parent and parent.dom then + if parent.dom and parent.dom.src and parent.dom.src.__tag == "element" then + assert(parent.dom == originalNode, "parent dom is not original node") + parent.dom = node + else + local found = false + for j = 1, #parent.dom do + if parent.dom[j] == originalNode then + parent.dom[j] = node + found = true + break + end + end + + local found2 = false + for k, v in pairs(parent.keyed) do + if v == originalNode then + parent.keyed[k] = node + found2 = true + break + end + end + + assert(found, "not found" .. parent.src.name) + assert(found2, "not found2" .. parent.src.name) + end + elseif not parent then + tree = node -- root + else + error("SHIT") + end + + end + + return tree +end + +---Renders a Solyd element via tree expansion and sweeping, pass the return value to the next invocation. +---@param previousTree table? +---@param rootComponent SolydElement +---@return table? +function Solyd.render(previousTree, rootComponent) + local wasHook = __hook + + local tree = _render(previousTree, rootComponent) + repeat + setChange = false + tree = _cleanDirty(tree) + until not setChange + + __hook = wasHook -- Support nested render calls + + return tree +end + +---Wrap a component function to allow calling it directly without invoking createElement. +---@generic P: table +---@param component fun(props: P): table +---@return fun(props: P): table +function Solyd.wrapComponent(name, component) + return function(props) + return Solyd.createElement(name, component, props, props.key) + end +end + +return Solyd diff --git a/products.lua b/products.lua new file mode 100644 index 0000000..73888ba --- /dev/null +++ b/products.lua @@ -0,0 +1,28 @@ +return { + { + modid = "minecraft:lapis_lazuli", + name = "Lapis Lazuli", + address = "lapis", + price = 1.0, + priceOverrides = { + { + currency = "tenebra", + price = 1.0 + } + }, + }, + { + modid = "minecraft:diamond_pickaxe", + name = "Diamond Pickaxe eff5", + address = "dpick", + price = 50.0, + predicates = { + enchantments = { + { + fullName = "Efficiency", + level = 4 + } + } + } + } +} \ No newline at end of file diff --git a/radon.lua b/radon.lua new file mode 100644 index 0000000..2b8180b --- /dev/null +++ b/radon.lua @@ -0,0 +1,126 @@ +--- Imports +local _ = require("util.score") + +local display = require("modules.display") + +local Solyd = require("modules.solyd") +local hooks = require("modules.hooks") +local useCanvas = hooks.useCanvas + +local BigText = require("components.BigText") +local RenderCanvas = require("components.RenderCanvas") +--local Core = require("core.GameState") +local ShopRunner = require("core.ShopRunner") +local ConfigValidator = require("core.ConfigValidator") + +local loadRIF = require("modules.rif") + +local config = require("config") +local products = require("products") +--- End Imports + +ConfigValidator.validateConfig(config) +ConfigValidator.validateProducts(products) + +local Main = Solyd.wrapComponent("Main", function(props) + local canvas = useCanvas() + + return _.flat { + BigText { text="Radon Shop", x=1, y=1, bg=colors.red, width=display.bgCanvas.width }, + }, { + canvas = {canvas, 1, 1}, + gameState = props.gameState or {} + } +end) + + + +local t = 0 +local tree = nil +local lastClock = os.epoch("utc") + +local lastCanvasStack = {} +local lastCanvasHash = {} +local function diffCanvasStack(newStack) + -- Find any canvases that were removed + local removed = {} + local kept, newCanvasHash = {}, {} + for i = 1, #lastCanvasStack do + removed[lastCanvasStack[i][1]] = lastCanvasStack[i] + end + for i = 1, #newStack do + if removed[newStack[i][1]] then + kept[#kept+1] = newStack[i] + removed[newStack[i][1]] = nil + newStack[i][1].allDirty = false + else -- New + newStack[i][1].allDirty = true + end + + newCanvasHash[newStack[i][1]] = newStack[i] + end + + -- Mark rectangle of removed canvases on bgCanvas (TODO: using bgCanvas is a hack) + for _, canvas in pairs(removed) do + display.bgCanvas:dirtyRect(canvas[2], canvas[3], canvas[1].width, canvas[1].height) + end + + -- For each kept canvas, mark the bounds if the new bounds are different + for i = 1, #kept do + local newCanvas = kept[i] + local oldCanvas = lastCanvasHash[newCanvas[1]] + if oldCanvas then + if oldCanvas[2] ~= newCanvas[2] or oldCanvas[3] ~= newCanvas[3] then + -- TODO: Optimize this? + display.bgCanvas:dirtyRect(oldCanvas[2], oldCanvas[3], oldCanvas[1].width, oldCanvas[1].height) + display.bgCanvas:dirtyRect(newCanvas[2], newCanvas[3], newCanvas[1].width, newCanvas[1].height) + end + end + end + + lastCanvasStack = newStack + lastCanvasHash = newCanvasHash +end + +--local shopState = Core.ShopState.new() +local shopState = nil + +local deltaTimer = os.startTimer(0) +ShopRunner.launchShop(shopState, function() + while true do + tree = Solyd.render(tree, Main {t = t, gameState = shopState}) + + local context = Solyd.getTopologicalContext(tree, { "canvas", "aabb" }) + + diffCanvasStack(context.canvas) + + local t1 = os.epoch("utc") + display.ccCanvas:composite({display.bgCanvas, 1, 1}, unpack(context.canvas)) + display.ccCanvas:outputDirty(display.mon) + local t2 = os.epoch("utc") + -- print("Render time: " .. (t2-t1) .. "ms") + + local e = { os.pullEvent() } + local name = e[1] + if name == "timer" and e[2] == deltaTimer then + local clock = os.epoch("utc") + local dt = (clock - lastClock)/1000 + t = t + dt + lastClock = clock + deltaTimer = os.startTimer(0) + + hooks.tickAnimations(dt) + --[[elseif name == "monitor_touch" then + local x, y = e[3], e[4] + local player = auth.reconcileTouch(x, y) + if player then + local node = hooks.findNodeAt(context.aabb, x, y) + if node then + node.onClick(player) + end + else + -- TODO: Yell at the players + end]] + end + end +end) diff --git a/res/cfont.rif b/res/cfont.rif new file mode 100644 index 0000000..2e4b273 --- /dev/null +++ b/res/cfont.rif Binary files differ diff --git a/res/font.rif b/res/font.rif new file mode 100644 index 0000000..b8a3de9 --- /dev/null +++ b/res/font.rif Binary files differ diff --git a/util/iter.lua b/util/iter.lua new file mode 100644 index 0000000..1784a22 --- /dev/null +++ b/util/iter.lua @@ -0,0 +1,12 @@ +-- Iterator for list +local function list(xs) + local i = 0 + return function() + i = i + 1 + return xs[i] + end +end + +return { + list = list, +} diff --git a/util/misc.lua b/util/misc.lua new file mode 100644 index 0000000..cc7b752 --- /dev/null +++ b/util/misc.lua @@ -0,0 +1,43 @@ +local _ = require("util.score") + +local Solyd = require("modules.solyd") +local Canvases = require("modules.canvas") +local PixelCanvas = Canvases.PixelCanvas + +local function tableSize(t) + if type(t) ~= "table" then + return nil + end + + local count = 0 + for k, v in pairs(t) do + count = count + 1 + end + return count +end + +---Renders a Solyd tree and bakes the canvases into one +function bakeToCanvas(rootComponent) + local tree = Solyd.render(nil, rootComponent) + local context = Solyd.getTopologicalContext(tree, { "canvas" }) + local minX, minY = math.huge, math.huge + local maxX, maxY = -math.huge, -math.huge + + for _, canvas in ipairs(context.canvas) do + minX = math.min(minX, canvas[2]) + minY = math.min(minY, canvas[3]) + maxX = math.max(maxX, canvas[2] + canvas[1].width - 1) + maxY = math.max(maxY, canvas[3] + canvas[1].height - 1) + end + + local canvas = PixelCanvas.new(maxX - minX + 1, maxY - minY + 1) + canvas:composite(_.map(context.canvas, function(c) + return {c[1], c[2] - minX + 1, c[3] - minY + 1} + end)) + return canvas +end + +return { + tableSize = tableSize, + bakeToCanvas = bakeToCanvas, +} diff --git a/util/riko.lua b/util/riko.lua new file mode 100644 index 0000000..e67d4d6 --- /dev/null +++ b/util/riko.lua @@ -0,0 +1,30 @@ +require("modules.rif") -- Initialize colors + +return function(t) + local rikoPalette = { + {24, 24, 24}, -- black + {29, 43, 82}, -- blue + {126, 37, 83}, -- purple + {0, 134, 81}, -- green + {171, 81, 54}, -- brown + {86, 86, 86}, -- gray + {157, 157, 157}, -- lightGray + {255, 0, 76}, -- red + {255, 163, 0}, -- orange + {255, 240, 35}, -- yellow + {0, 231*0.7, 85*0.7}, -- lime + {41, 173, 255}, -- cyan + {130, 118, 156}, -- magenta + {255, 119, 169}, -- pink + {0, 231*0.5, 85*0.5}, -- darkGreen + {236, 236, 236}, -- white + } + + for i = 1, #rikoPalette do + for j = 1, 3 do + rikoPalette[i][j] = rikoPalette[i][j] / 255 + end + + t.setPaletteColor(2^(i - 1), unpack(rikoPalette[i])) + end +end diff --git a/util/score.lua b/util/score.lua new file mode 100644 index 0000000..dfec612 --- /dev/null +++ b/util/score.lua @@ -0,0 +1,144 @@ +local score = {} + +---Applies {fun} to each element of {list} and returns a list of the results. +---@generic T, U +---@param list T[] +---@param fun fun(x: T, i: integer): U +---@return U[] +function score.map(list, fun) + local result = {__type = "list"} + for i, v in ipairs(list) do + result[i] = fun(v, i) + end + return result +end + +function score.range(start, stop, step) + local result = {__type = "list"} + if step == nil then + step = 1 + end + if stop == nil then + stop = start + start = 1 + end + for i = start, stop, step do + result[#result + 1] = i + end + return result +end + +function score.rangeMap(start, stop, step, fun) + if type(start) == "function" then + fun = start + start = 1 + stop = nil + step = nil + elseif type(stop) == "function" then + fun = stop + stop = start + start = 1 + step = nil + elseif type(step) == "function" then + fun = step + step = 1 + end + + return score.map(score.range(start, stop, step), fun) +end + +---Returns a list of the elements from {list} that satisfy the predicate {fun}. +---@generic T +---@param list T[] +---@param fun fun(x: T): boolean +---@return T[] +function score.filter(list, fun) + local result = {__type = "list"} + for i, v in ipairs(list) do + if fun(v) then + result[#result + 1] = v + end + end + return result +end + +function score.intersectSeq(list1, list2) + local result = {__type = "list"} + if #list1 > #list2 then + list1, list2 = list2, list1 + end + + for i = #list1 + 1, #list2 do + result[#result + 1] = list2[i] + end + return result +end + +function score.flat(list) + local result = {__type = "list"} + for i, v in ipairs(list) do + if v.__type == "list" then + for j, w in ipairs(v) do + result[#result + 1] = w + end + else + result[#result + 1] = v + end + end + return result +end + +---Fisher yates shuffle +---@generic T +---@param list T[] +---@return T[] +function score.shuffle(list) + local n = #list + while n > 2 do + local k = math.random(n) + list[n], list[k] = list[k], list[n] + n = n - 1 + end + return list +end + +function score.append(list, value) + list = {unpack(list)} + list[#list + 1] = value + return list +end + +---Deeply copies a table, except for tables that have a __opaque property. +---@generic T: table +---@param t T +---@return T +function score.copyDeep(t) + if type(t) ~= "table" then + return t + end + + local copy = {} + for k, v in pairs(t) do + if type(v) == "table" and v.__opaque == nil and v.__nocopy == nil then + copy[k] = score.copyDeep(v) + else + copy[k] = v + end + end + return copy +end + +---@generic T +---@param list T[] +---@return T[] +function score.filterTruthy(list) + local result = {__type = "list"} + for i, v in ipairs(list) do + if v then + result[#result + 1] = v + end + end + return result +end + +return score