diff --git a/level_editor/editor.lua b/level_editor/editor.lua index a8fc5cf..a6603c5 100644 --- a/level_editor/editor.lua +++ b/level_editor/editor.lua @@ -1,11 +1,11 @@ require "level_editor/object_attributes" require "level_editor/gui" +require "level_editor/layout" editorView = "room" --can be "off", "room", "world", "asset" selectedColor = "red" local bacSprite = love.graphics.newImage("art/_ui/bac.png") -local forwardSprite = love.graphics.newImage("art/_ui/forward.png") local folderSprite = love.graphics.newImage("art/_ui/folder.png") local cancelSprite = love.graphics.newImage("art/_ui/cancel.png") local confirmSprite = love.graphics.newImage("art/_ui/confirm.png") @@ -14,7 +14,6 @@ local clearSavesSprite = love.graphics.newImage("art/_ui/clear_saves.png") local selectedFolder = nil local selectedAsset = nil local selectedRoom = nil -local selectedRow = 1 local gameAssets = {} local inputText = "" local assetProperties = {} @@ -25,7 +24,8 @@ local activeField = nil --this is for whenwe are doing text input things local uiButtons = {} local folderButtons = {} -local assetButtons = { {} } --table for rows so we can do stuff +local assetListButtons = {} -- flat list of the selected folder's assets (scrolls) +local listScroll = { scroll = 0 } -- shared vertical scroll for the room list local editingAssetButtons = {} local labels = {} @@ -45,6 +45,110 @@ local assetSearchButtons = {} local worldSearchText = "" local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 } +-- ── Room-view sidebar layout ──────────────────────────────────────────────── +-- Declared up here (not next to editorDraw) so editorMouseHandler, defined +-- earlier in the file, can still capture these as upvalues. + +-- Which flat list the room view is currently showing (search > folder > roots). +local function currentRoomList() + if assetSearchText ~= "" then return assetSearchButtons + elseif not selectedFolder then return folderButtons + else return assetListButtons end +end + +-- Carve the sidebar into fixed, non-overlapping "divs": a search header pinned +-- to the top, a control cluster pinned to the bottom, a status strip above it, +-- and a flexible list filling whatever is left. Recomputed every frame so it +-- tracks window resizes. +local function roomSections() + local pad = 10 + local w = math.max(0, sb.w - pad * 2) + local controlsH = 104 + local statusH = 44 + local search = { x = pad, y = pad, w = w, h = 34 } + local controls = { x = pad, y = sb.h - pad - controlsH, w = w, h = controlsH } + local status = { x = pad, y = controls.y - statusH, w = w, h = statusH } + local listY = search.y + search.h + 8 + local list = { x = pad, y = listY, w = w, h = math.max(0, status.y - 8 - listY) } + return { search = search, listRect = list, status = status, controls = controls } +end + +-- Position the bottom control cluster inside its section: level-name field, a +-- row of colour buttons, then a row of action buttons. Runs for both drawing +-- and hit-testing so the two never disagree. +local function layoutRoomControls(c, s) + local vgap = 4 -- vertical space between rows + local hgap = 8 -- horizontal space between buttons in a row + local y = c.y + + s.labelY = y + y = y + 15 + + local field = uiButtons.roomName + if field then field.x = c.x; field.y = y; field.w = c.w; field.h = 22 end + y = y + 22 + vgap + + -- Resize a sprite button to a target height in place, so all control sizing + -- lives here instead of being scattered across the creation calls. + local function sizeBtn(name, target) + local b = uiButtons[name] + if b and b.sprite then + b.scale = target / b.sprite:getHeight() + b.w = b.sprite:getWidth() * b.scale + b.h = b.sprite:getHeight() * b.scale + end + return b + end + + -- Pack the row centered with a fixed gap, rather than spreading each button + -- across a full-width slot (which left big gaps between small icons). + local function placeRow(names, target) + local btns, total = {}, 0 + for _, name in ipairs(names) do + local b = sizeBtn(name, target) + if b then table.insert(btns, b); total = total + b.w end + end + total = total + hgap * math.max(0, #btns - 1) + local x = c.x + (c.w - total) / 2 + for _, b in ipairs(btns) do + b.x = x + b.y = y + (target - b.h) / 2 + x = x + b.w + hgap + end + y = y + target + vgap + end + + placeRow({ "white", "red", "green", "blue" }, 22) + placeRow({ "save", "clearSaves", "world" }, 28) +end + +-- Build the full room-view layout (sections + a scroll list + a back/search +-- header) without drawing. Shared by editorDraw and editorMouseHandler. +local function buildRoomLayout() + local s = roomSections() + + local showBack = assetSearchText ~= "" or selectedFolder + local sx, sw = s.search.x, s.search.w + if showBack then + s.back = { x = s.search.x, y = s.search.y, w = s.search.h, h = s.search.h } + sx = s.back.x + s.back.w + 6 + sw = s.search.w - s.back.w - 6 + end + s.searchBox = { x = sx, y = s.search.y, w = sw, h = s.search.h } + + s.items = currentRoomList() + s.list = Layout.list(listScroll, s.listRect, 40, #s.items, 2) + + layoutRoomControls(s.controls, s) + return s +end + +-- Mouse wheel scrolls the room list; clamped by Layout.list next frame. +function editorScrollRoomList(dy) + listScroll.scroll = math.max(0, (listScroll.scroll or 0) - dy * 40) +end +-- ──────────────────────────────────────────────────────────────────────────── + local function fuzzyMatches(value, query) query = (query or ""):lower():gsub("%s+", "") if query == "" then return true end @@ -66,6 +170,7 @@ end local function rebuildAssetSearchButtons() assetSearchButtons = {} + listScroll.scroll = 0 if assetSearchText == "" then return end local names = {} for asset in pairs(gameAssets) do @@ -103,12 +208,12 @@ local function rebuildWorldRoomButtons() for index = first, math.min(first + worldPageSize - 1, #names) do local roomName = names[index] local row = index - first - table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sideBarBox.w - 20, h = 60 }) + table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sb.w - 20, h = 60 }) end worldControls = { - { text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sideBarBox.h - 194, w = sideBarBox.w - 20, h = 34, + { text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sb.h - 194, w = sb.w - 20, h = 34, onClic = function() worldMode = worldMode == "place" and "jump" or "place"; rebuildWorldRoomButtons() end }, - { text = "RESEED OLD EXITS", x = 10, y = sideBarBox.h - 152, w = sideBarBox.w - 20, h = 34, + { text = "RESEED OLD EXITS", x = 10, y = sb.h - 152, w = sb.w - 20, h = 34, onClic = function() local now = love.timer.getTime() if now > worldSeedConfirmationExpires then @@ -121,9 +226,9 @@ local function rebuildWorldRoomButtons() editorStatus = "Seeded " .. result.placed .. " rooms; " .. result.links .. " legacy links (" .. result.conflicts .. " conflicts kept separate)." rebuildWorldRoomButtons() end }, - { text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sideBarBox.h - 110, w = sideBarBox.w - 20, h = 34, + { text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sb.h - 110, w = sb.w - 20, h = 34, onClic = function(buttonX) - if buttonX < sideBarBox.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end + if buttonX < sb.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end rebuildWorldRoomButtons() end }, } @@ -131,8 +236,8 @@ end local function drawWorldSidebar() love.graphics.setColor(gColor.white:set()) - love.graphics.printf("ROOMS — " .. (worldMode == "place" and "select to place; re-click to clear" or "click a map room to open"), 8, 6, sideBarBox.w - 16, "center") - worldSearchBox.w = sideBarBox.w - 20 + love.graphics.printf("ROOMS — " .. (worldMode == "place" and "select to place; re-click to clear" or "click a map room to open"), 8, 6, sb.w - 16, "center") + worldSearchBox.w = sb.w - 20 love.graphics.rectangle("line", worldSearchBox.x, worldSearchBox.y, worldSearchBox.w, worldSearchBox.h) love.graphics.printf("SEARCH: " .. worldSearchText, worldSearchBox.x + 6, worldSearchBox.y + 7, worldSearchBox.w - 12, "left") for _, button in ipairs(worldRoomButtons) do @@ -147,6 +252,10 @@ local function drawWorldSidebar() love.graphics.rectangle("line", control.x, control.y, control.w, control.h) love.graphics.printf(control.text, control.x, control.y + 9, control.w, "center") end + -- Pin the assets/world toggle to a fixed spot here; the room view owns its + -- position otherwise, so give the world view a stable one of its own. + uiButtons.world.x = sb.w / 2 - uiButtons.world.w / 2 + uiButtons.world.y = sb.h - uiButtons.world.h - 12 uiButtons.world:draw() end @@ -254,7 +363,7 @@ function _initLevelEditor() name = "folder", text = folder, x = 30, - y = (sideBarBox.h - 50) * (counter / 15), + y = (sb.h - 50) * (counter / 15), sprite = folderSprite, onClic = { left = function(self) @@ -283,9 +392,9 @@ function _initLevelEditor() end }, - x = sideBarBox.w / 4, - y = sideBarBox.h * (9 / 10) - }) + x = sb.w / 4, + y = sb.h * (9 / 10) + }, "save") _addUIButton(createUIElement{ name = "world", @@ -298,8 +407,8 @@ function _initLevelEditor() end }, - x = sideBarBox.w * (3 / 4), - y = sideBarBox.h * (9 / 10) + x = sb.w * (3 / 4), + y = sb.h * (9 / 10) }, "world") _addUIButton(createUIElement{ @@ -308,10 +417,10 @@ function _initLevelEditor() onClic = { left = clearRuntimeSaves, }, - x = sideBarBox.w / 2, - y = sideBarBox.h * (9 / 10), - scale = drawScale / 3 - }) + x = sb.w / 2, + y = sb.h * (9 / 10), + scale = editorScale / 3 + }, "clearSaves") for _, color in ipairs({"white", "red", "green", "blue"}) do _addUIButton(createUIElement{ @@ -327,16 +436,16 @@ function _initLevelEditor() end }, - x = sideBarBox.w * (_ / 5), - y = sideBarBox.h * (8 / 10) - }) + x = sb.w * (_ / 5), + y = sb.h * (8 / 10) + }, color) end roomNameField = createUIElement{ name = "roomName", text = editorRoomName, x = 25, - y = sideBarBox.h * (7 / 10), + y = sb.h * (7 / 10), onClic = { left = beginRoomNameEdit, } @@ -356,74 +465,29 @@ end function selectFolder(folder) selectedFolder = folder - - local bacButton = _addUIButton(createUIElement{ - name = "bacToFolderView", - sprite = bacSprite, - x = sideBarBox.w / 2, - y = 37.5, - onClic = { - left = function(self) - print(self, self.x) - self = nil - folderView(self) - end, - right = function(self) return end - } - }, "bacToFolderView") - local row = 1 - local counter = 1 - local limit = 10 + listScroll.scroll = 0 + assetListButtons = {} table.sort(hierarchy[selectedFolder]) for _, asset in ipairs(hierarchy[selectedFolder]) do - if counter % limit == 0 then - counter = 1 - - local forwardButton = createUIElement{ - name = "forward", - sprite = forwardSprite, - x = sideBarBox.w / 2, - y = sideBarBox.h - 250, - onClic = { - left = function() - selectedRow = selectedRow + 1 - end, - right = function() - - end - } - } - table.insert(assetButtons[row], forwardButton) - - row = row + 1 - table.insert(assetButtons, {}) - end - local sprites = {} - for color, location in pairs(gameAssets[asset].sprites) do sprites[color] = love.graphics.newImage(location) end - local assetButton = createUIElement{ + -- x/y are placeholders; the room-view layout repositions every item + -- each frame from the list section (see buildRoomLayout). + table.insert(assetListButtons, createUIElement{ sprites = sprites, name = "asset", text = asset, x = 30, - y = (sideBarBox.h - 225) * (counter / limit) + 20, + y = 0, onClic = { - left = function() - print(asset) - selectAsset(asset) - end, - right = function() - editAsset(asset) - end + left = function() selectAsset(asset) end, + right = function() editAsset(asset) end, } - } - table.insert(assetButtons[row], assetButton) - counter = counter + 1 + }) end end @@ -444,8 +508,8 @@ function editAsset(asset) local cancelButton = createUIElement{ name = "cancel", sprite = cancelSprite, - x = sideBarBox.w / 3, - y = sideBarBox.h - 75, + x = sb.w / 3, + y = sb.h - 75, onClic = { left = function(self) assetsView() @@ -457,8 +521,8 @@ function editAsset(asset) local confirmButton = createUIElement{ name = "confirm", sprite = confirmSprite, - x = sideBarBox.w * 2 / 3, - y = sideBarBox.h - 75, + x = sb.w * 2 / 3, + y = sb.h - 75, onClic = { left = function(self) saveAsset() @@ -478,12 +542,12 @@ function editAsset(asset) local assetButton = createUIElement{ name = asset, sprites = assetSprites, - x = sideBarBox.w / 2 - 18, + x = sb.w / 2 - 18, y = 80, } local classFieldLabel = { - draw = function() love.graphics.printf("CLASS", 0, 125, sideBarBox.w, "center") end + draw = function() love.graphics.printf("CLASS", 0, 125, sb.w, "center") end } labels[classFieldLabel] = classFieldLabel @@ -501,7 +565,7 @@ function editAsset(asset) } local preloadLabel = { - draw = function() love.graphics.printf("preload", 0, 225, sideBarBox.w, "center") end + draw = function() love.graphics.printf("preload", 0, 225, sb.w, "center") end } labels[preloadLabel] = preloadLabel @@ -540,10 +604,9 @@ function _addUIButton(button, name) end function folderView() - selectedRow = 1 selectedFolder = nil - assetButtons = {{}} - uiButtons.bacToFolderView = nil + assetListButtons = {} + listScroll.scroll = 0 labels = {} end @@ -594,39 +657,45 @@ end function editorMouseHandler(x, y, button) if x > width then - --we need to adjust for sidebar coords - x = x - width + -- map from real window coords into the sidebar's fixed virtual space + -- (same transform used to scale the sidebar canvas up in love.draw) + x = (x - width) * sb.w / sideBarBox.w + y = y * sb.h / sideBarBox.h if editorView == "room" then - if buttonContains({ x = 10, y = 5, w = sideBarBox.w - 20, h = 28 }, x, y) then - beginSearch("assetSearch") + local s = buildRoomLayout() + + -- back button: drop the search, else leave the folder + if s.back and Layout.hit(s.back, x, y) then + if assetSearchText ~= "" then + assetSearchText = "" + inputText = "" + activeField = nil + rebuildAssetSearchButtons() + elseif selectedFolder then + folderView() + end return - elseif assetSearchText ~= "" then - for _, uiElement in ipairs(assetSearchButtons) do - if uiElement:clicIsInBox(x, y) then - uiElement:onClic(button) - return - end - end - elseif not selectedFolder then - for _, uiElement in pairs(folderButtons) do - if uiElement:clicIsInBox(x, y) then - uiElement:onClic(button) - return - end - end - else - for _, uiElement in pairs(assetButtons[selectedRow]) do - if uiElement:clicIsInBox(x, y) then - uiElement:onClic(button) - return - end - end end - for _, uiElement in pairs(uiButtons) do - if uiElement:clicIsInBox(x, y) then - uiElement:onClic(button) + if Layout.hit(s.searchBox, x, y) then + beginSearch("assetSearch") + return + end + + -- scrolling list item + local i = s.list.at(x, y) + if i and s.items[i] then + s.items[i]:onClic(button) + return + end + + -- control cluster (only the buttons that live there) + for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do + local b = uiButtons[name] + if b and b:clicIsInBox(x, y) then + b:onClic(button) + return end end elseif editorView == "asset" then @@ -722,25 +791,44 @@ end function editorDraw() if editorView == "room" then + local s = buildRoomLayout() love.graphics.setColor(gColor.white:set()) - love.graphics.rectangle("line", 10, 5, sideBarBox.w - 20, 28) - love.graphics.printf("ASSET SEARCH: " .. assetSearchText, 16, 12, sideBarBox.w - 32, "left") - love.graphics.setColor(gColor.white:set()) - love.graphics.printf("LEVEL NAME", 0, sideBarBox.h * (7 / 10) - 24, sideBarBox.w, "center") - for _, button in pairs(uiButtons) do - button:draw() + + -- header: optional back button + search box + if s.back then + love.graphics.rectangle("line", s.back.x, s.back.y, s.back.w, s.back.h) + local sc = (s.back.h - 8) / bacSprite:getHeight() + love.graphics.draw(bacSprite, s.back.x + 4, s.back.y + 4, 0, sc, sc) end - if assetSearchText ~= "" then - for _, asset in ipairs(assetSearchButtons) do asset:draw() end - elseif not selectedFolder then - for _, folder in pairs(folderButtons) do - folder:draw() - end - else - for _, entity in ipairs(assetButtons[selectedRow]) do - entity:draw() + love.graphics.rectangle("line", s.searchBox.x, s.searchBox.y, s.searchBox.w, s.searchBox.h) + love.graphics.printf("SEARCH: " .. assetSearchText, s.searchBox.x + 6, s.searchBox.y + 9, s.searchBox.w - 12, "left") + + -- scrolling list, clipped to its section so nothing bleeds into others + Layout.clip(s.listRect) + for i, item in ipairs(s.items) do + if s.list.visible(i) then + item.x, item.y = s.list.pos(i) + item.w = s.listRect.w + item:draw() end end + Layout.unclip() + Layout.scrollbar(s.list) + + -- control cluster + love.graphics.setColor(gColor.white:set()) + love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center") + for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do + if uiButtons[name] then uiButtons[name]:draw() end + end + + -- status strip between the list and the controls + if editorStatus then + Layout.clip(s.status) + love.graphics.setColor(gColor.white:set()) + love.graphics.printf(editorStatus, s.status.x, s.status.y + 4, s.status.w, "center") + Layout.unclip() + end elseif editorView == "asset" then for _, button in pairs(editingAssetButtons) do button:draw() @@ -752,8 +840,10 @@ function editorDraw() for _, label in pairs(labels) do label:draw() end - if editorStatus then + -- The room view draws its status inside its own strip (see the room branch); + -- the other views keep the simple centered line. + if editorStatus and editorView ~= "room" then love.graphics.setColor(gColor.white:set()) - love.graphics.printf(editorStatus, 10, sideBarBox.h * (6 / 10), sideBarBox.w - 20, "center") + love.graphics.printf(editorStatus, 10, sb.h * (6 / 10), sb.w - 20, "center") end end diff --git a/level_editor/gui.lua b/level_editor/gui.lua index d5260fd..55db806 100644 --- a/level_editor/gui.lua +++ b/level_editor/gui.lua @@ -26,7 +26,7 @@ function GUI:initialize(t) if t.sprite then print(self.name) - self.scale = t.scale or drawScale * 2 / 3 + self.scale = t.scale or editorScale * 2 / 3 self.w = t.sprite:getWidth() * self.scale self.h = t.sprite:getHeight() * self.scale self.sprite = t.sprite @@ -41,22 +41,22 @@ function GUI:initialize(t) h = sprite:getHeight() end if w > h then - self.scale = (drawScale * (2 / 3)) / (w / 16) + self.scale = (editorScale * (2 / 3)) / (w / 16) elseif w < h then - self.scale = (drawScale * (2 / 3)) / (h / 16) + self.scale = (editorScale * (2 / 3)) / (h / 16) else - self.scale = (drawScale * (2 / 3)) / (w / 16) + self.scale = (editorScale * (2 / 3)) / (w / 16) end - self.w = sideBarBox.w - (self.x * 2) + self.w = sb.w - (self.x * 2) self.h = h * self.scale else -- this is text self.x, self.y = t.x, t.y - self.w, self.h = sideBarBox.w - self.x * 2, 50 + self.w, self.h = sb.w - self.x * 2, 50 end if self.name == "folder" then - self.w = sideBarBox.w - self.x * 2 + self.w = sb.w - self.x * 2 end end @@ -106,9 +106,14 @@ function GUI:draw() if self.text then love.graphics.setColor(gColor.white:set()) if not self.sprites and not self.sprite then + -- text-only element (e.g. the level-name field): center in its rect love.graphics.rectangle("line", box.x, box.y, box.w, box.h) + local th = love.graphics.getFont():getHeight() + love.graphics.printf(self.text, box.x, box.y + (box.h - th) / 2, box.w, "center") + else + -- label sitting to the right of an icon + love.graphics.print(self.text, box.x + 50, box.y + 15) end - love.graphics.print(self.text, box.x + 50, box.y + 15) end end diff --git a/level_editor/layout.lua b/level_editor/layout.lua new file mode 100644 index 0000000..ab2b655 --- /dev/null +++ b/level_editor/layout.lua @@ -0,0 +1,63 @@ +-- A dead-simple layout helper for the editor sidebar. Everything works in the +-- sidebar's local coordinate space (0,0 = top-left of sideBarCanvas) — the same +-- space editorDraw / editorMouseHandler already use — so section rects double as +-- love.graphics.setScissor rects and as hit boxes. No dependencies, no state of +-- its own beyond the scroll table the caller hands in. + +Layout = {} + +-- point-in-rect +function Layout.hit(r, x, y) + return x >= r.x and x < r.x + r.w and y >= r.y and y < r.y + r.h +end + +-- Clip drawing to a section. setScissor ignores the active graphics transform, +-- so multiply the virtual-space rect by editorRenderScale to hit real canvas +-- pixels. Clamp to non-negative: a too-narrow/short sidebar (e.g. a square +-- window with no room for it) would otherwise hand setScissor a negative size. +function Layout.clip(r) + local s = editorRenderScale or 1 + love.graphics.setScissor(r.x * s, r.y * s, math.max(0, r.w * s), math.max(0, r.h * s)) +end +function Layout.unclip() love.graphics.setScissor() end + +-- A vertical scrolling list confined to rect `r`. `state` is any table the +-- caller keeps between frames to hold `state.scroll`; it is clamped here. +-- Returns helpers to place / cull / hit-test `count` items of height `itemH`. +function Layout.list(state, r, itemH, count, gap) + gap = gap or 6 + local step = itemH + gap + local content = count * step + local maxScroll = math.max(0, content - r.h) + state.scroll = math.max(0, math.min(state.scroll or 0, maxScroll)) + local scroll = state.scroll + return { + rect = r, itemH = itemH, step = step, count = count, maxScroll = maxScroll, scroll = scroll, + -- top-left corner of item i (1-based), in sidebar space + pos = function(i) return r.x, r.y + (i - 1) * step - scroll end, + -- is item i at all inside the visible window? (cheap cull) + visible = function(i) + local y = r.y + (i - 1) * step - scroll + return y + itemH > r.y and y < r.y + r.h + end, + -- index at a sidebar-space point, or nil if outside the list / no item + at = function(x, y) + if not Layout.hit(r, x, y) then return nil end + local i = math.floor((y - r.y + scroll) / step) + 1 + if i >= 1 and i <= count then return i end + return nil + end, + } +end + +-- Thin scrollbar thumb on the right edge of a list, drawn only when it scrolls. +function Layout.scrollbar(list) + if list.maxScroll <= 0 then return end + local r = list.rect + local thumb = math.max(16, r.h * (r.h / (r.h + list.maxScroll))) + local ty = r.y + (r.h - thumb) * (list.scroll / list.maxScroll) + local pr, pg, pb, pa = love.graphics.getColor() + love.graphics.setColor(255, 255, 255, 90) + love.graphics.rectangle("fill", r.x + r.w - 4, ty, 3, thumb) + love.graphics.setColor(pr, pg, pb, pa) +end diff --git a/libs/hump b/libs/hump deleted file mode 160000 index 08937cc..0000000 --- a/libs/hump +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 08937cc0ecf72d1a964a8de6cd552c5e136bf0d4 diff --git a/libs/hump/.travis.yml b/libs/hump/.travis.yml new file mode 100644 index 0000000..31ae16e --- /dev/null +++ b/libs/hump/.travis.yml @@ -0,0 +1,38 @@ +dist: precise +sudo: false # Use container-based infrastructure +language: python + +git: + depth: 4 + +#Testing with lastest stable version of LUAJIT +env: + - LUA_RUNTIME="luajit" + LUA_VERSION="2.0.5" + #- LUA_RUNTIME="luajit" + # LUA_VERSION="2.1" + +before_install: + - pip install hererocks + - hererocks lua_installations/$LUA_RUNTIME$LUA_VERSION/ --luarocks ^ --$LUA_RUNTIME $LUA_VERSION + - export PATH=$PATH:$PWD/lua_installations/$LUA_RUNTIME$LUA_VERSION/bin + +before_script: + - luarocks install busted + - busted --version + +script: + - busted --output=TAP #default utfTerminal + +#Checks if Luajit and luarocks are already installed +#Comment out to reinstall or if changing versions +cache: + directories: + - $PWD/lua_installations + +# Can add email notification here +#notifications: + #email: + #recipients: + #- email@here.com + #on_failure: always #default always diff --git a/libs/hump/README.md b/libs/hump/README.md new file mode 100644 index 0000000..22eedff --- /dev/null +++ b/libs/hump/README.md @@ -0,0 +1,48 @@ +hump - Helper Utilities for Massive Progression +=============================================== + +**hump** is a small collection of tools for developing games with LÖVE. +[![Build Status](https://travis-ci.org/vrld/hump.svg?branch=master)](https://travis-ci.org/vrld/hump) + +Contents: +------------ + +* *gamestate.lua*: Easy gamestate management. +* *timer.lua*: Delayed and time-limited function calls and tweening. +* *vector.lua*: 2D vector math. +* *vector-light.lua*: Lightweight 2D vector math (for optimisation purposes - leads to potentially ugly code). +* *class.lua*: Lightweight object orientation (class or prototype based). +* *signal.lua*: Simple Signal/Slot (aka. Observer) implementation. +* *camera.lua*: Move-, zoom- and rotatable camera with camera locking and movement smoothing. + +Documentation +============= + +You can find the documentation here: [hump.readthedocs.org](http://hump.readthedocs.org) + + +License +======= +> Copyright (c) 2010-2018 Matthias Richter +> +> 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. +> +> Except as contained in this notice, the name(s) of the above copyright holders +> shall not be used in advertising or otherwise to promote the sale, use or +> other dealings in this Software without prior written authorization. +> +> 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. diff --git a/libs/hump/camera.lua b/libs/hump/camera.lua new file mode 100644 index 0000000..cb86a79 --- /dev/null +++ b/libs/hump/camera.lua @@ -0,0 +1,216 @@ +--[[ +Copyright (c) 2010-2015 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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 _PATH = (...):match('^(.*[%./])[^%.%/]+$') or '' +local cos, sin = math.cos, math.sin + +local camera = {} +camera.__index = camera + +-- Movement interpolators (for camera locking/windowing) +camera.smooth = {} + +function camera.smooth.none() + return function(dx,dy) return dx,dy end +end + +function camera.smooth.linear(speed) + assert(type(speed) == "number", "Invalid parameter: speed = "..tostring(speed)) + return function(dx,dy, s) + -- normalize direction + local d = math.sqrt(dx*dx+dy*dy) + local dts = math.min((s or speed) * love.timer.getDelta(), d) -- prevent overshooting the goal + if d > 0 then + dx,dy = dx/d, dy/d + end + + return dx*dts, dy*dts + end +end + +function camera.smooth.damped(stiffness) + assert(type(stiffness) == "number", "Invalid parameter: stiffness = "..tostring(stiffness)) + return function(dx,dy, s) + local dts = love.timer.getDelta() * (s or stiffness) + return dx*dts, dy*dts + end +end + + +local function new(x,y, zoom, rot, smoother) + x,y = x or love.graphics.getWidth()/2, y or love.graphics.getHeight()/2 + zoom = zoom or 1 + rot = rot or 0 + smoother = smoother or camera.smooth.none() -- for locking, see below + return setmetatable({x = x, y = y, scale = zoom, rot = rot, smoother = smoother}, camera) +end + +function camera:lookAt(x,y) + self.x, self.y = x, y + return self +end + +function camera:move(dx,dy) + self.x, self.y = self.x + dx, self.y + dy + return self +end + +function camera:position() + return self.x, self.y +end + +function camera:rotate(phi) + self.rot = self.rot + phi + return self +end + +function camera:rotateTo(phi) + self.rot = phi + return self +end + +function camera:zoom(mul) + self.scale = self.scale * mul + return self +end + +function camera:zoomTo(zoom) + self.scale = zoom + return self +end + +function camera:attach(x,y,w,h, noclip) + x,y = x or 0, y or 0 + w,h = w or love.graphics.getWidth(), h or love.graphics.getHeight() + + self._sx,self._sy,self._sw,self._sh = love.graphics.getScissor() + if not noclip then + love.graphics.setScissor(x,y,w,h) + end + + local cx,cy = x+w/2, y+h/2 + love.graphics.push() + love.graphics.translate(cx, cy) + love.graphics.scale(self.scale) + love.graphics.rotate(self.rot) + love.graphics.translate(-self.x, -self.y) +end + +function camera:detach() + love.graphics.pop() + love.graphics.setScissor(self._sx,self._sy,self._sw,self._sh) +end + +function camera:draw(...) + local x,y,w,h,noclip,func + local nargs = select("#", ...) + if nargs == 1 then + func = ... + elseif nargs == 5 then + x,y,w,h,func = ... + elseif nargs == 6 then + x,y,w,h,noclip,func = ... + else + error("Invalid arguments to camera:draw()") + end + + self:attach(x,y,w,h,noclip) + func() + self:detach() +end + +-- world coordinates to camera coordinates +function camera:cameraCoords(x,y, ox,oy,w,h) + ox, oy = ox or 0, oy or 0 + w,h = w or love.graphics.getWidth(), h or love.graphics.getHeight() + + -- x,y = ((x,y) - (self.x, self.y)):rotated(self.rot) * self.scale + center + local c,s = cos(self.rot), sin(self.rot) + x,y = x - self.x, y - self.y + x,y = c*x - s*y, s*x + c*y + return x*self.scale + w/2 + ox, y*self.scale + h/2 + oy +end + +-- camera coordinates to world coordinates +function camera:worldCoords(x,y, ox,oy,w,h) + ox, oy = ox or 0, oy or 0 + w,h = w or love.graphics.getWidth(), h or love.graphics.getHeight() + + -- x,y = (((x,y) - center) / self.scale):rotated(-self.rot) + (self.x,self.y) + local c,s = cos(-self.rot), sin(-self.rot) + x,y = (x - w/2 - ox) / self.scale, (y - h/2 - oy) / self.scale + x,y = c*x - s*y, s*x + c*y + return x+self.x, y+self.y +end + +function camera:mousePosition(ox,oy,w,h) + local mx,my = love.mouse.getPosition() + return self:worldCoords(mx,my, ox,oy,w,h) +end + +-- camera scrolling utilities +function camera:lockX(x, smoother, ...) + local dx, dy = (smoother or self.smoother)(x - self.x, self.y, ...) + self.x = self.x + dx + return self +end + +function camera:lockY(y, smoother, ...) + local dx, dy = (smoother or self.smoother)(self.x, y - self.y, ...) + self.y = self.y + dy + return self +end + +function camera:lockPosition(x,y, smoother, ...) + return self:move((smoother or self.smoother)(x - self.x, y - self.y, ...)) +end + +function camera:lockWindow(x, y, x_min, x_max, y_min, y_max, smoother, ...) + -- figure out displacement in camera coordinates + x,y = self:cameraCoords(x,y) + local dx, dy = 0,0 + if x < x_min then + dx = x - x_min + elseif x > x_max then + dx = x - x_max + end + if y < y_min then + dy = y - y_min + elseif y > y_max then + dy = y - y_max + end + + -- transform displacement to movement in world coordinates + local c,s = cos(-self.rot), sin(-self.rot) + dx,dy = (c*dx - s*dy) / self.scale, (s*dx + c*dy) / self.scale + + -- move + self:move((smoother or self.smoother)(dx,dy,...)) +end + +-- the module +return setmetatable({new = new, smooth = camera.smooth}, + {__call = function(_, ...) return new(...) end}) diff --git a/libs/hump/class.lua b/libs/hump/class.lua new file mode 100644 index 0000000..7d62707 --- /dev/null +++ b/libs/hump/class.lua @@ -0,0 +1,98 @@ +--[[ +Copyright (c) 2010-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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 function include_helper(to, from, seen) + if from == nil then + return to + elseif type(from) ~= 'table' then + return from + elseif seen[from] then + return seen[from] + end + + seen[from] = to + for k,v in pairs(from) do + k = include_helper({}, k, seen) -- keys might also be tables + if to[k] == nil then + to[k] = include_helper({}, v, seen) + end + end + return to +end + +-- deeply copies `other' into `class'. keys in `other' that are already +-- defined in `class' are omitted +local function include(class, other) + return include_helper(class, other, {}) +end + +-- returns a deep copy of `other' +local function clone(other) + return setmetatable(include({}, other), getmetatable(other)) +end + +local function new(class) + -- mixins + class = class or {} -- class can be nil + local inc = class.__includes or {} + if getmetatable(inc) then inc = {inc} end + + for _, other in ipairs(inc) do + if type(other) == "string" then + other = _G[other] + end + include(class, other) + end + + -- class implementation + class.__index = class + class.init = class.init or class[1] or function() end + class.include = class.include or include + class.clone = class.clone or clone + + -- constructor call + return setmetatable(class, {__call = function(c, ...) + local o = setmetatable({}, c) + o:init(...) + return o + end}) +end + +-- interface for cross class-system compatibility (see https://github.com/bartbes/Class-Commons). +if class_commons ~= false and not common then + common = {} + function common.class(name, prototype, parent) + return new{__includes = {prototype, parent}} + end + function common.instance(class, ...) + return class(...) + end +end + + +-- the module +return setmetatable({new = new, include = include, clone = clone}, + {__call = function(_,...) return new(...) end}) diff --git a/libs/hump/docs/Makefile b/libs/hump/docs/Makefile new file mode 100644 index 0000000..2ce17ef --- /dev/null +++ b/libs/hump/docs/Makefile @@ -0,0 +1,192 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/hump.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/hump.qhc" + +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/hump" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/hump" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/libs/hump/docs/_static/graph-tweens.js b/libs/hump/docs/_static/graph-tweens.js new file mode 100644 index 0000000..046b55a --- /dev/null +++ b/libs/hump/docs/_static/graph-tweens.js @@ -0,0 +1,218 @@ +(function() { +"use strict"; + +// DISCLAIMER: I just started learning d3, so this is certainly not good +// idiomatic d3 code. But hey, it works (kinda). + +var tweens = { + 'out': function(f) { return function(s) { return 1-f(1-s) } }, + 'chain': function(f1, f2) { return function(s) { return ((s<.5) ? f1(2*s) : 1+f2(2*s-1)) * .5 } }, + 'linear': function(s) { return s }, + 'quad': function(s) { return s*s }, + 'cubic': function(s) { return s*s*s }, + 'quart': function(s) { return s*s*s*s }, + 'quint': function(s) { return s*s*s*s*s }, + 'sine': function(s) { return 1 - Math.cos(s*Math.PI/2) }, + 'expo': function(s) { return Math.pow(2, 10*(s-1)) }, + 'circ': function(s) { return 1 - Math.sqrt(Math.max(0,1-s*s)) }, + 'back': function(s) { var b = 1.70158; return s*s*((b+1)*s - b) }, + 'bounce': function(s) { + return Math.min( + 7.5625 * Math.pow(s, 2), + 7.5625 * Math.pow((s - .545455), 2) + .75, + 7.5625 * Math.pow((s - .818182), 2) + .90375, + 7.5625 * Math.pow((s - .954546), 2) + .984375) + }, + 'elastic': function(s) { + return -Math.sin(2/0.3 * Math.PI * (s-1) - Math.asin(1)) * Math.pow(2, 10*(s-1)) + }, +}; +var tweenfunc = tweens.linear; + + +var width_graph = 320, + width_anim_move = 110, + width_anim_rotate = 110, + width_anim_size = 110, + height = 250; + +// "UI" +var graph_ui = d3.select("#tween-graph").append("div") + .attr("id", "tween-graph-ui"); +// rest see below + +// the graph +var graph = d3.select("#tween-graph").append("svg") + .attr("width", width_graph).attr("height", height); + +// background +graph.append("rect") + .attr("width", "100%").attr("height", "100%") + .attr("style", "fill:rgb(240,240,240);stroke-width:1;stroke:rgb(100,100,100);"); + +var y_zero = height * .78, y_one = height * .22; +graph.append("rect") + .attr("y", y_one) + .attr("width", "100%").attr("height", y_zero - y_one) + .attr("style", "fill:steelblue;fill-opacity:.3;stroke-width:1;stroke:rgba(100,100,100,.7)"); + +// time arrow +graph.append("defs") + .append("marker") + .attr("id", "triangle") + .attr("viewBox", "0 0 10 10") + .attr("refX", 1).attr("refY", 5) + .attr("markerWidth", 4) + .attr("markerHeight", 4) + .attr("orient", "auto") + .attr("style", "fill:rgba(0,0,0,.5)") + .append("path").attr("d", "M 0 0 L 10 5 L 0 10 z"); + +graph.append("line") + .attr("x1", width_graph/2-80) + .attr("x2", width_graph/2+80) + .attr("y1", y_zero + 40).attr("y2", y_zero + 40) + .attr("style", "stroke-width:2;stroke:rgba(0,0,0,.5)") + .attr("marker-end", "url(#triangle)"); + +graph.append("text") + .text("Time") + .attr("x", width_graph/2).attr("y", y_zero + 55) + .attr("style", "text-anchor:middle;fill:rgba(0,0,0,.5);font-size:15px"); + +// the actual graph +var curve = d3.svg.line() + .x(function(x) { return x*width_graph; }) + .y(function(x) { return tweenfunc(x) * (y_one - y_zero) + y_zero; }) + +var graph_curve = graph.append("path").attr("d", curve(d3.range(0,1.05,.005))) + .attr("style", "fill:none;stroke-width:2;stroke:seagreen;"); + +var graph_marker = graph.append("circle") + .attr("r", 5) + .attr("style", "stroke:goldenrod;fill:none;stroke-width:3"); + +// finally, a label +var graph_label = graph.append("text") + .text("linear") + .attr("x", width_graph/2).attr("y", 20) + .attr("style", "text-anchor:middle;font-weight:bold;font-size:15px;"); + + +// animation examples - moving ball +var anim_move = d3.select("#tween-graph").append("svg") + .attr("width", width_anim_move).attr("height", height); + +anim_move.append("rect") + .attr("width", "100%").attr("height", "100%") + .attr("style", "fill:rgb(240,240,240);stroke-width:1;stroke:rgb(100,100,100);"); + +anim_move.append("rect") + .attr("width", 10).attr("height", (y_zero - y_one)) + .attr("x", width_anim_move/2-5).attr("y", y_one) + .attr("style", "fill:black;opacity:.1"); + +var anim_move_ball = anim_move.append("circle") + .attr("cx", width_anim_move/2).attr("cy", y_one) + .attr("r", 17) + .attr("style", "fill:steelblue;stroke:rgb(90,90,90);stroke-width:5;"); + +// animation examples - rotating square +var anim_rotate = d3.select("#tween-graph").append("svg") + .attr("width", width_anim_size).attr("height", height); + +anim_rotate.append("rect") + .attr("width", "100%").attr("height", "100%") + .attr("style", "fill:rgb(240,240,240);stroke-width:1;stroke:rgb(100,100,100);"); + +var w = width_anim_size/2; +var anim_rotate_square = anim_rotate.append("rect") + .attr("x", -w/2).attr("y", -w/4) + .attr("width", w).attr("height", w/2) + .attr("style", "fill:steelblue;stroke:rgb(90,90,90);stroke-width:5;"); + +// animation examples - resizing ellipse +var anim_size = d3.select("#tween-graph").append("svg") + .attr("width", width_anim_size).attr("height", height); + +anim_size.append("rect") + .attr("width", "100%").attr("height", "100%") + .attr("style", "fill:rgb(240,240,240);stroke-width:1;stroke:rgb(100,100,100);"); + +anim_size.append("ellipse") + .attr("cx", width_anim_size/2).attr("cy", height/2) + .attr("rx", 40).attr("ry", 120) + .attr("style", "fill:rgb(150,150,150);stroke:black;stroke-width:2;opacity:.1"); + +var anim_size_ellipse = anim_size.append("ellipse") + .attr("cx", width_anim_size/2).attr("cy", height/2) + .attr("rx", 40).attr("ry", 40) + .attr("style", "fill:steelblue;stroke:rgb(90,90,90);stroke-width:5;"); + + +// make it move! +var t = 0; +window.setInterval(function() { + t = (t + .025 / 3); + if (t > 1.3) { t = -.3; } + var tt = Math.max(Math.min(t, 1), 0); + + var s = tweenfunc(tt) + var yy = s * (y_one - y_zero) + y_zero; + var translate = "translate("+(width_anim_size/2)+" "+(height/2)+")"; + var rotate = "rotate(" + (s * 360) + ")"; + + graph_marker.attr("cx", tt*width_graph).attr("cy", yy); + anim_move_ball.attr("cy", y_one + y_zero - yy); + anim_rotate_square.attr("transform", translate + " " + rotate); + anim_size_ellipse.attr("ry", s * 80 + 40); +}, 25); + + +// ui continued +graph_ui.append("strong").text("Function: "); +var select_modifier = graph_ui.append("select"); +select_modifier.append("option").text("in"); +select_modifier.append("option").text("out"); +select_modifier.append("option").text("in-out"); +select_modifier.append("option").text("out-in"); +graph_ui.append("strong").text("-") + +var select_func = graph_ui.append("select") +var funcs = []; +for (var k in tweens) +{ + if (k != "out" && k != "chain") + { + select_func.append("option").text(k); + funcs.push(k); + } +} + +var change_tweenfunc = function() +{ + var fname = funcs[select_func.node().selectedIndex]; + var mod = select_modifier.node().selectedIndex; + + tweenfunc = tweens[fname]; + if (mod == 1) // out + tweenfunc = tweens.out(tweenfunc); + else if (mod == 2) // in-out + tweenfunc = tweens.chain(tweenfunc, tweens.out(tweenfunc)); + else if (mod == 3) // out-in + tweenfunc = tweens.chain(tweens.out(tweenfunc), tweenfunc); + + // update curve + graph_curve.attr("d", curve(d3.range(0,1.05,.005))) + + // update label + if (mod != 0) + graph_label.text((['in','out','in-out','out-in'])[mod] + "-" + fname); + else + graph_label.text(fname); +} + +select_func.on("change", change_tweenfunc); +select_modifier.on("change", change_tweenfunc); + +})(); diff --git a/libs/hump/docs/_static/in-out-interpolators.png b/libs/hump/docs/_static/in-out-interpolators.png new file mode 100644 index 0000000..f401f56 Binary files /dev/null and b/libs/hump/docs/_static/in-out-interpolators.png differ diff --git a/libs/hump/docs/_static/interpolators.png b/libs/hump/docs/_static/interpolators.png new file mode 100644 index 0000000..e2c9ded Binary files /dev/null and b/libs/hump/docs/_static/interpolators.png differ diff --git a/libs/hump/docs/_static/inv-interpolators.png b/libs/hump/docs/_static/inv-interpolators.png new file mode 100644 index 0000000..0daa377 Binary files /dev/null and b/libs/hump/docs/_static/inv-interpolators.png differ diff --git a/libs/hump/docs/_static/vector-cross.png b/libs/hump/docs/_static/vector-cross.png new file mode 100644 index 0000000..e774b77 Binary files /dev/null and b/libs/hump/docs/_static/vector-cross.png differ diff --git a/libs/hump/docs/_static/vector-mirrorOn.png b/libs/hump/docs/_static/vector-mirrorOn.png new file mode 100644 index 0000000..aaaf582 Binary files /dev/null and b/libs/hump/docs/_static/vector-mirrorOn.png differ diff --git a/libs/hump/docs/_static/vector-perpendicular.png b/libs/hump/docs/_static/vector-perpendicular.png new file mode 100644 index 0000000..61dd671 Binary files /dev/null and b/libs/hump/docs/_static/vector-perpendicular.png differ diff --git a/libs/hump/docs/_static/vector-projectOn.png b/libs/hump/docs/_static/vector-projectOn.png new file mode 100644 index 0000000..3112440 Binary files /dev/null and b/libs/hump/docs/_static/vector-projectOn.png differ diff --git a/libs/hump/docs/_static/vector-rotated.png b/libs/hump/docs/_static/vector-rotated.png new file mode 100644 index 0000000..4426420 Binary files /dev/null and b/libs/hump/docs/_static/vector-rotated.png differ diff --git a/libs/hump/docs/camera.rst b/libs/hump/docs/camera.rst new file mode 100644 index 0000000..606f7e8 --- /dev/null +++ b/libs/hump/docs/camera.rst @@ -0,0 +1,567 @@ +hump.camera +=========== + +:: + + Camera = require "hump.camera" + +A camera utility for LÖVE. A camera can "look" at a position. It can zoom in +and out and it can rotate it's view. In the background, this is done by +actually moving, scaling and rotating everything in the game world. But don't +worry about that. + +**Example**:: + + function love.load() + camera = Camera(player.pos.x, player.pos.y) + end + + function love.update(dt) + local dx,dy = player.x - camera.x, player.y - camera.y + camera:move(dx/2, dy/2) + end + + function love.draw() + camera:attach() + -- do your drawing here + camera:detach() + end + +List of Functions +----------------- + +* :func:`Camera.new(x,y, zoom, rot) ` +* :func:`camera:move(dx,dy) ` +* :func:`camera:lookAt(x,y) ` +* :func:`camera:position() ` +* :func:`camera:rotate(angle) ` +* :func:`camera:rotateTo(angle) ` +* :func:`camera:zoom(mul) ` +* :func:`camera:zoomTo(zoom) ` +* :func:`camera:attach() ` +* :func:`camera:detach() ` +* :func:`camera:draw(func) ` +* :func:`camera:worldCoords(x, y) ` +* :func:`camera:cameraCoords(x, y) ` +* :func:`camera:mousePosition() ` +* :func:`camera:lockX(x, smoother, ...) ` +* :func:`camera:lockY(y, smoother, ...) ` +* :func:`camera:lockPosition(x,y, smoother, ...) ` +* :func:`camera:lockWindow(x,y, x_min, x_max, y_min, y_max, smoother, ...) ` +* :func:`Camera.smooth.none() ` +* :func:`Camera.smooth.linear(speed) ` +* :func:`Camera.smooth.damped(stiffness) ` + +Function Reference +------------------ + +.. function:: Camera.new(x,y, zoom, rot) + + :param numbers x,y: Point for the camera to look at. (optional) + :param number zoom: Camera zoom. (optional) + :param number rot: Camera rotation in radians. (optional) + :returns: A new camera. + + +Creates a new camera. You can access the camera position using ``camera.x, +camera.y``, the zoom using ``camera.scale`` and the rotation using ``camera.rot``. + +The module variable name can be used at a shortcut to ``new()``. + +**Example**:: + + camera = require 'hump.camera' + -- camera looking at (100,100) with zoom 2 and rotated by 45 degrees + cam = camera(100,100, 2, math.pi/2) + + +.. function:: camera:move(dx,dy) + + :param numbers dx,dy: Direction to move the camera. + :returns: The camera. + + +Move the camera *by* some vector. To set the position, use +:func:`camera:lookAt`. + +This function is shortcut to ``camera.x,camera.y = camera.x+dx, camera.y+dy``. + +**Examples**:: + + function love.update(dt) + camera:move(dt * 5, dt * 6) + end + +:: + + function love.update(dt) + camera:move(dt * 5, dt * 6):rotate(dt) + end + + +.. function:: camera:lookAt(x,y) + + :param numbers x,y: Position to look at. + :returns: The camera. + + +Let the camera look at a point. In other words, it sets the camera position. To +move the camera *by* some amount, use :func:`camera:move`. + +This function is shortcut to ``camera.x,camera.y = x, y``. + +**Examples**:: + + function love.update(dt) + camera:lookAt(player.pos:unpack()) + end + +:: + + function love.update(dt) + camera:lookAt(player.pos:unpack()):rotate(player.rot) + end + +.. function:: camera:position() + + :returns: ``x,y`` -- Camera position. + + +Returns ``camera.x, camera.y``. + +**Example**:: + + -- let the camera fly! + local cam_dx, cam_dy = 0, 0 + + function love.mousereleased(x,y) + local cx,cy = camera:position() + dx, dy = x-cx, y-cy + end + + function love.update(dt) + camera:move(dx * dt, dy * dt) + end + + +.. function:: camera:rotate(angle) + + :param number angle: Rotation angle in radians + :returns: The camera. + + +Rotate the camera by some angle. To set the angle use :func:`camera:rotateTo`. + +This function is shortcut to ``camera.rot = camera.rot + angle``. + +**Examples**:: + + function love.update(dt) + camera:rotate(dt) + end + +:: + + function love.update(dt) + camera:rotate(dt):move(dt,dt) + end + + +.. function:: camera:rotateTo(angle) + + :param number angle: Rotation angle in radians + :returns: The camera. + +Set rotation: ``camera.rot = angle``. + +**Example**:: + + camera:rotateTo(math.pi/2) + + +.. function:: camera:zoom(mul) + + :param number mul: Zoom change. Should be > 0. + :returns: The camera. + + +*Multiply* zoom: ``camera.scale = camera.scale * mul``. + +**Examples**:: + + camera:zoom(2) -- make everything twice as big + +:: + + camera:zoom(0.5) -- ... and back to normal + +:: + + camera:zoom(-1) -- mirror and flip everything upside down + + +.. function:: camera:zoomTo(zoom) + + :param number zoom: New zoom. + :returns: The camera. + + +Set zoom: ``camera.scale = zoom``. + +**Example**:: + + camera:zoomTo(1) -- reset zoom + + +.. function:: camera:attach() + +Start looking through the camera. + +Apply camera transformations, i.e. move, scale and rotate everything until +``camera:detach()`` as if looking through the camera. + +**Example**:: + + function love.draw() + camera:attach() + draw_world() + camera:detach() + + draw_hud() + end + + +.. function:: camera:detach() + +Stop looking through the camera. + +**Example**:: + + function love.draw() + camera:attach() + draw_world() + camera:detach() + + draw_hud() + end + + +.. function:: camera:draw(func) + + :param function func: Drawing function to be wrapped. + +Wrap a function between a ``camera:attach()``/``camera:detach()`` pair. +Equivalent to:: + + camera:attach() + func() + camera:detach() + + +**Example**:: + + function love.draw() + camera:draw(draw_world) + draw_hud() + end + + +.. function:: camera:worldCoords(x, y) + + :param numbers x, y: Point to transform. + :returns: ``x,y`` -- Transformed point. + +Because a camera has a point it looks at, a rotation and a zoom factor, it +defines a coordinate system. A point now has two sets of coordinates: One +defines where the point is to be found in the game world, and the other +describes the position on the computer screen. The first set of coordinates is +called world coordinates, the second one camera coordinates. Sometimes it is +needed to convert between the two coordinate systems, for example to get the +position of a mouse click in the game world in a strategy game, or to see if an +object is visible on the screen. + +:func:`camera:worldCoords` and :func:`camera:cameraCoords` transform points +between these two coordinate systems. + +**Example**:: + + x,y = camera:worldCoords(love.mouse.getPosition()) + selectedUnit:plotPath(x,y) + + +.. function:: camera:cameraCoords(x, y) + + :param numbers x, y: Point to transform. + :returns: ``x,y`` -- Transformed point. + + +Because a camera has a point it looks at, a rotation and a zoom factor, it +defines a coordinate system. A point now has two sets of coordinates: One +defines where the point is to be found in the game world, and the other +describes the position on the computer screen. The first set of coordinates is +called world coordinates, the second one camera coordinates. Sometimes it is +needed to convert between the two coordinate systems, for example to get the +position of a mouse click in the game world in a strategy game, or to see if an +object is visible on the screen. + +:func:`camera:worldCoords` and :func:`camera:cameraCoords` transform points +between these two coordinate systems. + +**Example**:: + + x,y = camera:cameraCoords(player.pos.x, player.pos.y) + love.graphics.line(x, y, love.mouse.getPosition()) + + +.. function:: camera:mousePosition() + + :returns: Mouse position in world coordinates. + + +Shortcut to ``camera:worldCoords(love.mouse.getPosition())``. + +**Example**:: + + x,y = camera:mousePosition() + selectedUnit:plotPath(x,y) + + +Camera Movement Control +----------------------- + +Camera movement is one of these things that go almost unnoticed when done well, +but add a lot to the overall experience. +The article `Scroll Back: The Theory and Practice of Cameras in SideScrollers +`_ +by Itay Keren gives a lot of insight into how to design good camera systems. + +**hump.camera** offers functions that help to implement most of the techniques +discussed in the article. The functions :func:`camera:lockX`, +:func:`camera:lockY`, :func:`camera:lockPosition`, and :func:`camera:lockWindow` +move the camera so that the interesting content stays in frame. +Note that the functions must be called every frame:: + + function love.update() + -- vertical locking + camera:lockX(player.pos.x) + end + + +All movements are subject to smoothing (see :ref:`Movement Smoothers +`). +You can specify a default movement smoother by assigning the variable +:attr:`camera.smoother`:: + + cam.smoother = Camera.smooth.linear(100) + + + +.. function:: camera:lockX(x, smoother, ...) + + :param number x: X coordinate (in world coordinates) to lock to. + :param function smoother: Movement smoothing override. (optional) + :param mixed ...: Additional parameters to the smoothing function. (optional) + +Horizontal camera locking: Keep the camera locked on the defined ``x``-position +(in *world coordinates*). The ``y``-position is not affected. + +You can define an off-center locking position by "aiming" the camera left or +right of your actual target. For example, to center the player 20 pixels to the +*left* of the screen, aim 20 pixels to it's *right* (see examples). + +**Examples**:: + + -- lock on player vertically + camera:lockX(player.x) + +:: + + -- ... with linear smoothing at 25 px/s + camera:lockX(player.x, Camera.smooth.linear(25)) + +:: + + -- lock player 20px left of center + camera:lockX(player.x + 20) + + + +.. function:: camera:lockY(y, smoother, ...) + + :param number y: Y coordinate (in world coordinates) to lock to. + :param function smoother: Movement smoothing override. (optional) + :param mixed ...: Additional parameters to the smoothing function. (optional) + +Vertical camera locking: Keep the camera locked on the defined ``y``-position +(in *world coordinates*). The ``x``-position is not affected. + +You can define an off-center locking position by "aiming" the camera above or +below your actual target. For example, to center the player 20 pixels *below* the +screen center, aim 20 pixels *above* it (see examples). + +**Examples**:: + + -- lock on player horizontally + camera:lockY(player.y) + +:: + + -- ... with damped smoothing with a stiffness of 10 + camera:lockY(player.y, Camera.smooth.damped(10)) + +:: + + -- lock player 20px below the screen center + camera:lockY(player.y - 20) + + + +.. function:: camera:lockPosition(x,y, smoother, ...) + + :param numbers x,y: Position (in world coordinates) to lock to. + :param function smoother: Movement smoothing override. (optional) + :param mixed ...: Additional parameters to the smoothing function. (optional) + +Horizontal and vertical camera locking: Keep the camera locked on the defined +position (in *world coordinates*). + +You can define an off-center locking position by "aiming" the camera to the +opposite direction away from your real target. +For example, to center the player 10 pixels to the *left* and 20 pixels *above* +the screen center, aim 10 pixels to the *right* and 20 pixels *below*. + +**Examples**:: + + -- lock on player + camera:lockPosition(player.x, player.y) + +:: + + -- lock 50 pixels into player's aiming direction + camera:lockPosition(player.x - player.aiming.x * 50, player.y - player.aiming.y * 50) + + + +.. function:: camera:lockWindow(x,y, x_min, x_max, y_min, y_max, smoother, ...) + + :param numbers x,y: Position (in world coordinates) to lock to. + :param numbers x_min: Upper left X coordinate of the camera window *(in camera coordinates!)*. + :param numbers x_max: Lower right X coordinate of the camera window *(in camera coordinates!)*. + :param numbers y_min: Upper left Y coordinate of the camera window *(in camera coordinates!)*. + :param numbers y_max: Lower right Y coordinate of the camera window *(in camera coordinates!)*. + :param function smoother: Movement smoothing override. (optional) + :param mixed ...: Additional parameters to the smoothing function. (optional) + +The most powerful locking method: Lock camera to ``x,y``, but only move the +camera if the position would be out of the screen-rectangle defined by ``x_min``, +``x_max``, ``y_min``, ``y_max``. + +.. note:: + The locking window is defined in camera coordinates, whereas the position to + lock to is defined in world coordinates! + +All of the other locking methods can be implemented by window locking. For +position locking, set ``x_min = x_max`` and ``y_min = y_max``. +Off-center locking can be done by defining the locking window accordingly. + +**Examples**:: + + -- lock on player + camera:lock(player.x, player.y) + +.. attribute:: camera.smoother + +The default smoothing operator. Must be a ``function`` with the following +prototype:: + + function customSmoother(dx,dy, ...) + do_stuff() + return new_dx,new_dy + end + +where ``dx,dy`` is the offset the camera would move before smoothing and +``new_dx, new_dy`` is the offset the camera should move after smoothing. + + +.. _movement-smoothers: + +Movement Smoothers +^^^^^^^^^^^^^^^^^^ + +It is not always desirable that the camera instantly locks on a target. +`Platform snapping +`_, +for example, would look terrible if the camera would instantly jump to the +focussed platform. +Smoothly moving the camera to the locked position can also give the illusion of +a camera operator an add to the overall feel of your game. + +**hump.camera** allows to smooth the movement by either passing movement +smoother functions to the locking functions or by setting a default smoother +(see :attr:`camera.smoother`). + +Smoothing functions must have the following prototype:: + + function customSmoother(dx,dy, ...) + do_stuff() + return new_dx,new_dy + end + +where ``dx,dy`` is the offset the camera would move before smoothing and +``new_dx, new_dy`` is the offset the camera should move after smoothing. + +This is a simple "rubber-band" smoother:: + + function rubber_band(dx,dy) + local dt = love.timer.getDelta() + return dx*dt, dy*dt + end + +**hump.camera** defines generators for the most common smoothers: + +.. function:: Camera.smooth.none() + + :returns: Smoothing function. + +Dummy smoother: does not smooth the motion. + +**Example**:: + + cam.smoother = Camera.smooth.none() + + +.. function:: Camera.smooth.linear(speed) + + :param number speed: Smoothing speed. + :returns: Smoothing function. + +Smoothly moves the camera towards to snapping goal with constant speed. + +**Examples**:: + + cam.smoother = Camera.smooth.linear(100) + +:: + + -- warning: creates a function every frame! + camera:lockX(player.x, Camera.smooth.linear(25)) + + +.. function:: Camera.smooth.damped(stiffness) + + :param number stiffness: Speed of the camera movement. + :returns: Smoothing function. + +Smoothly moves the camera towards the goal with a speed proportional to the +distance to the target. +Stiffness defines the speed of the motion: Higher values mean that the camera +moves more quickly. + +**Examples**:: + + cam.smoother = Camera.smooth.damped(10) + +:: + + -- warning: creates a function every frame! + camera:lockPosition(player.x, player.y, Camera.smooth.damped(2)) diff --git a/libs/hump/docs/class.rst b/libs/hump/docs/class.rst new file mode 100644 index 0000000..78b7147 --- /dev/null +++ b/libs/hump/docs/class.rst @@ -0,0 +1,353 @@ +hump.class +========== + +:: + + Class = require "hump.class" + +A small, fast class/prototype implementation with multiple inheritance. + +Implements `class commons `_. + +**Example**:: + + Critter = Class{ + init = function(self, pos, img) + self.pos = pos + self.img = img + end, + speed = 5 + } + + function Critter:update(dt, player) + -- see hump.vector + local dir = (player.pos - self.pos):normalize_inplace() + self.pos = self.pos + dir * Critter.speed * dt + end + + function Critter:draw() + love.graphics.draw(self.img, self.pos.x, self.pos.y) + end + +List of Functions +----------------- + +* :func:`Class.new() ` +* :func:`class.init(object, ...) ` +* :func:`Class:include(other) ` +* :func:`class:clone() ` + +Function Reference +------------------ + +.. function:: Class.new() + Class.new({init = constructor, __includes = parents, ...}) + + :param function constructor: Class constructor. Can be accessed with ``theclass.init(object, ...)``. (optional) + :param class or table of classes parents: Classes to inherit from. Can either be a single class or a table of classes. (optional) + :param mixed ...: Any other fields or methods common to all instances of this class. (optional) + :returns: The class. + + +Declare a new class. + +``init()`` will receive the new object instance as first argument. Any other +arguments will also be forwarded (see examples), i.e. ``init()`` has the +following signature:: + + function init(self, ...) + +If you do not specify a constructor, an empty constructor will be used instead. + +The name of the variable that holds the module can be used as a shortcut to +``new()`` (see example). + +**Examples**:: + + Class = require 'hump.class' -- `Class' is now a shortcut to new() + + -- define a class class + Feline = Class{ + init = function(self, size, weight) + self.size = size + self.weight = weight + end; + -- define a method + stats = function(self) + return string.format("size: %.02f, weight: %.02f", self.size, self.weight) + end; + } + + -- create two objects + garfield = Feline(.7, 45) + felix = Feline(.8, 12) + + print("Garfield: " .. garfield:stats(), "Felix: " .. felix:stats()) + +:: + + Class = require 'hump.class' + + -- same as above, but with 'external' function definitions + Feline = Class{} + + function Feline:init(size, weight) + self.size = size + self.weight = weight + end + + function Feline:stats() + return string.format("size: %.02f, weight: %.02f", self.size, self.weight) + end + + garfield = Feline(.7, 45) + print(Feline, garfield) + +:: + + Class = require 'hump.class' + A = Class{ + foo = function() print('foo') end + } + + B = Class{ + bar = function() print('bar') end + } + + -- single inheritance + C = Class{__includes = A} + instance = C() + instance:foo() -- prints 'foo' + instance:bar() -- error: function not defined + + -- multiple inheritance + D = Class{__includes = {A,B}} + instance = D() + instance:foo() -- prints 'foo' + instance:bar() -- prints 'bar' + +:: + + -- class attributes are shared across instances + A = Class{ foo = 'foo' } -- foo is a class attribute/static member + + one, two, three = A(), A(), A() + print(one.foo, two.foo, three.foo) --> prints 'foo foo foo' + + one.foo = 'bar' -- overwrite/specify for instance `one' only + print(one.foo, two.foo, three.foo) --> prints 'bar foo foo' + + A.foo = 'baz' -- overwrite for all instances without specification + print(one.foo, two.foo, three.foo) --> prints 'bar baz baz' + + +.. function:: class.init(object, ...) + + :param Object object: The object. Usually ``self``. + :param mixed ...: Arguments to pass to the constructor. + :returns: Whatever the parent class constructor returns. + + +Calls class constructor of a class on an object. + +Derived classes should use this function their constructors to initialize the +parent class(es) portions of the object. + +**Example**:: + + Class = require 'hump.class' + + Shape = Class{ + init = function(self, area) + self.area = area + end; + __tostring = function(self) + return "area = " .. self.area + end + } + + Rectangle = Class{__includes = Shape, + init = function(self, width, height) + Shape.init(self, width * height) + self.width = width + self.height = height + end; + __tostring = function(self) + local strs = { + "width = " .. self.width, + "height = " .. self.height, + Shape.__tostring(self) + } + return table.concat(strs, ", ") + end + } + + print( Rectangle(2,4) ) -- prints 'width = 2, height = 4, area = 8' + + +.. function:: Class:include(other) + + :param tables other: Parent classes/mixins. + :returns: The class. + + +Inherit functions and variables of another class, but only if they are not +already defined. This is done by (deeply) copying the functions and variables +over to the subclass. + +.. note:: + ``class:include()`` doesn't actually care if the arguments supplied are + hump classes. Just any table will work. + +.. note:: + You can use ``Class.include(a, b)`` to copy any fields from table ``a`` + to table ``b`` (see second example). + +**Examples**:: + + Class = require 'hump.class' + + Entity = Class{ + init = function(self) + GameObjects.register(self) + end + } + + Collidable = { + dispatch_collision = function(self, other, dx, dy) + if self.collision_handler[other.type]) + return collision_handler[other.type](self, other, dx, dy) + end + return collision_handler["*"](self, other, dx, dy) + end, + + collision_handler = {["*"] = function() end}, + } + + Spaceship = Class{ + init = function(self) + self.type = "Spaceship" + -- ... + end + } + + -- make Spaceship collidable + Spaceship:include(Collidable) + + Spaceship.collision_handler["Spaceship"] = function(self, other, dx, dy) + -- ... + end + +:: + + -- using Class.include() + Class = require 'hump.class' + a = { + foo = 'bar', + bar = {one = 1, two = 2, three = 3}, + baz = function() print('baz') end, + } + b = { + foo = 'nothing to see here...' + } + + Class.include(b, a) -- copy values from a to b + -- note that neither a nor b are hump classes! + + print(a.foo, b.foo) -- prints 'bar nothing to see here...' + + b.baz() -- prints 'baz' + + b.bar.one = 10 -- changes only values in b + print(a.bar.one, b.bar.one) -- prints '1 10' + + +.. function:: class:clone() + + :returns: A deep copy of the class/table. + + +Create a clone/deep copy of the class. + +.. note:: + You can use ``Class.clone(a)`` to create a deep copy of any table (see + second example). + +**Examples**:: + + Class = require 'hump.class' + + point = Class{ x = 0, y = 0 } + + a = point:clone() + a.x, a.y = 10, 10 + print(a.x, a.y) --> prints '10 10' + + b = point:clone() + print(b.x, b.y) --> prints '0 0' + + c = a:clone() + print(c.x, c.y) --> prints '10 10' + +:: + + -- using Class.clone() to copy tables + Class = require 'hump.class' + a = { + foo = 'bar', + bar = {one = 1, two = 2, three = 3}, + baz = function() print('baz') end, + } + b = Class.clone(a) + + b.baz() -- prints 'baz' + b.bar.one = 10 + print(a.bar.one, b.bar.one) -- prints '1 10' + + + +Caveats +------- + +Be careful when using metamethods like ``__add`` or ``__mul``: If a subclass +inherits those methods from a superclass, but does not overwrite them, the +result of the operation may be of the type superclass. Consider the following:: + + Class = require 'hump.class' + + A = Class{init = function(self, x) self.x = x end} + function A:__add(other) return A(self.x + other.x) end + function A:show() print("A:", self.x) end + + B = Class{init = function(self, x, y) A.init(self, x) self.y = y end} + function B:show() print("B:", self.x, self.y) end + function B:foo() print("foo") end + B:include(A) + + one, two = B(1,2), B(3,4) + result = one + two -- result will be of type A, *not* B! + result:show() -- prints "A: 4" + result:foo() -- error: method does not exist + +Note that while you can define the ``__index`` metamethod of the class, this is +not a good idea: It will break the class mechanism. To add a custom ``__index`` +metamethod without breaking the class system, you have to use ``rawget()``. But +beware that this won't affect subclasses:: + + Class = require 'hump.class' + + A = Class{} + function A:foo() print('bar') end + + function A:__index(key) + print(key) + return rawget(A, key) + end + + instance = A() + instance:foo() -- prints foo bar + + B = Class{__includes = A} + instance = B() + instance:foo() -- prints only foo + diff --git a/libs/hump/docs/conf.py b/libs/hump/docs/conf.py new file mode 100644 index 0000000..26894ca --- /dev/null +++ b/libs/hump/docs/conf.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +# +# hump documentation build configuration file, created by +# sphinx-quickstart on Sat Oct 10 13:10:12 2015. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os +import shlex + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.mathjax', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'hump' +copyright = u'2015, Matthias Richter' +author = u'Matthias Richter' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '1.0' +# The full version, including alpha/beta/rc tags. +release = '1.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'humpdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', + +# Latex figure (float) alignment +#'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'hump.tex', u'hump Documentation', + u'Matthias Richter', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'hump', u'hump Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'hump', u'hump Documentation', + author, 'hump', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + +primary_domain = "js" +highlight_language = "lua" + +import sphinx_rtd_theme +html_theme = 'sphinx_rtd_theme' +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] diff --git a/libs/hump/docs/gamestate.rst b/libs/hump/docs/gamestate.rst new file mode 100644 index 0000000..417bb92 --- /dev/null +++ b/libs/hump/docs/gamestate.rst @@ -0,0 +1,361 @@ +hump.gamestate +============== + +:: + + Gamestate = require "hump.gamestate" + +A gamestate encapsulates independent data and behaviour in a single table. + +A typical game could consist of a menu-state, a level-state and a game-over-state. + +**Example**:: + + local menu = {} -- previously: Gamestate.new() + local game = {} + + function menu:draw() + love.graphics.print("Press Enter to continue", 10, 10) + end + + function menu:keyreleased(key, code) + if key == 'return' then + Gamestate.switch(game) + end + end + + function game:enter() + Entities.clear() + -- setup entities here + end + + function game:update(dt) + Entities.update(dt) + end + + function game:draw() + Entities.draw() + end + + function love.load() + Gamestate.registerEvents() + Gamestate.switch(menu) + end + +List of Functions +----------------- + +* :func:`Gamestate.new() ` +* :func:`Gamestate.switch(to, ...) ` +* :func:`Gamestate.current() ` +* :func:`Gamestate.push(to, ...) ` +* :func:`Gamestate.pop(...) ` +* :func:`Gamestate.(...) >` +* :func:`Gamestate.registerEvents([callbacks]) ` + + +.. _callbacks: + +Gamestate Callbacks +------------------- + +A gamestate can define all callbacks that LÖVE defines. In addition, there are +callbacks for initalizing, entering and leaving a state: + +``init()`` + Called once, and only once, before entering the state the first time. See + :func:`Gamestate.switch`. + +``enter(previous, ...)`` + Called every time when entering the state. See :func:`Gamestate.switch`. + +``leave()`` + Called when leaving a state. See :func:`Gamestate.switch` and :func:`Gamestate.pop`. + +``resume()`` + Called when re-entering a state by :func:`Gamestate.pop`-ing another state. + +``update()`` + Update the game state. Called every frame. + +``draw()`` + Draw on the screen. Called every frame. + +``focus()`` + Called if the window gets or loses focus. + +``keypressed()`` + Triggered when a key is pressed. + +``keyreleased()`` + Triggered when a key is released. + +``mousepressed()`` + Triggered when a mouse button is pressed. + +``mousereleased()`` + Triggered when a mouse button is released. + +``joystickpressed()`` + Triggered when a joystick button is pressed. + +``joystickreleased()`` + Triggered when a joystick button is released. + +``quit()`` + Called on quitting the game. Only called on the active gamestate. + +When using :func:`Gamestate.registerEvents`, all these callbacks will be called by the +corresponding LÖVE callbacks and receive the same arguments (e.g. +``state:update(dt)`` will be called by ``love.update(dt)``). + +**Example**:: + + menu = {} -- previously: Gamestate.new() + + function menu:init() + self.background = love.graphics.newImage('bg.jpg') + Buttons.initialize() + end + + function menu:enter(previous) -- runs every time the state is entered + Buttons.setActive(Buttons.start) + end + + function menu:update(dt) -- runs every frame + Buttons.update(dt) + end + + function menu:draw() + love.graphics.draw(self.background, 0, 0) + Buttons.draw() + end + + function menu:keyreleased(key) + if key == 'up' then + Buttons.selectPrevious() + elseif key == 'down' then + Buttons.selectNext() + elseif + Buttons.active:onClick() + end + end + + function menu:mousereleased(x,y, mouse_btn) + local button = Buttons.hovered(x,y) + if button then + Button.select(button) + if mouse_btn == 'l' then + button:onClick() + end + end + end + + +Function Reference +------------------ + +.. function:: Gamestate.new() + + :returns: An empty table. + + +**Deprecated: Use the table constructor instead (see example)** + +Declare a new gamestate (just an empty table). A gamestate can define several +callbacks. + +**Example**:: + + menu = {} + -- deprecated method: + menu = Gamestate.new() + + +.. function:: Gamestate.switch(to, ...) + + :param Gamestate to: Target gamestate. + :param mixed ...: Additional arguments to pass to ``to:enter(current, ...)``. + :returns: The results of ``to:enter(current, ...)``. + + +Switch to a gamestate, with any additional arguments passed to the new state. + +Switching a gamestate will call the ``leave()`` callback on the current +gamestate, replace the current gamestate with ``to``, call the ``init()`` function +if, and only if, the state was not yet inialized and finally call +``enter(old_state, ...)`` on the new gamestate. + +.. note:: + Processing of callbacks is suspended until ``update()`` is called on the new + gamestate, but the function calling :func:`Gamestate.switch` can still continue - it is + your job to make sure this is handled correctly. See also the examples below. + + +**Examples**:: + + Gamestate.switch(game, level_two) + +:: + + -- stop execution of the current state by using return + if player.has_died then + return Gamestate.switch(game, level_two) + end + + -- this will not be called when the state is switched + player:update() + + + +.. function:: Gamestate.current() + + :returns: The active gamestate. + +Returns the currently activated gamestate. + +**Example**:: + + function love.keypressed(key) + if Gamestate.current() ~= menu and key == 'p' then + Gamestate.push(pause) + end + end + + +.. function:: Gamestate.push(to, ...) + + :param Gamestate to: Target gamestate. + :param mixed ...: Additional arguments to pass to ``to:enter(current, ...)``. + :returns: The results of ``to:enter(current, ...)``. + +Pushes the ``to`` on top of the state stack, i.e. makes it the active state. +Semantics are the same as ``switch(to, ...)``, except that ``leave()`` is *not* +called on the previously active state. + +Useful for pause screens, menus, etc. + +.. note:: + Processing of callbacks is suspended until ``update()`` is called on the + new gamestate, but the function calling ``GS.push()`` can still continue - + it is your job to make sure this is handled correctly. See also the + example below. + + +**Example**:: + + -- pause gamestate + Pause = Gamestate.new() + function Pause:enter(from) + self.from = from -- record previous state + end + + function Pause:draw() + local W, H = love.graphics.getWidth(), love.graphics.getHeight() + -- draw previous screen + self.from:draw() + -- overlay with pause message + love.graphics.setColor(0,0,0, 100) + love.graphics.rectangle('fill', 0,0, W,H) + love.graphics.setColor(255,255,255) + love.graphics.printf('PAUSE', 0, H/2, W, 'center') + end + + -- [...] + function love.keypressed(key) + if Gamestate.current() ~= menu and key == 'p' then + return Gamestate.push(pause) + end + end + + +.. function:: Gamestate.pop(...) + + :returns: The results of ``new_state:resume(...)``. + +Calls ``leave()`` on the current state and then removes it from the stack, making +the state below the current state and calls ``resume(...)`` on the activated state. +Does *not* call ``enter()`` on the activated state. + +.. note:: + Processing of callbacks is suspended until ``update()`` is called on the + new gamestate, but the function calling ``GS.pop()`` can still continue - + it is your job to make sure this is handled correctly. See also the + example below. + + +**Example**:: + + -- extending the example of Gamestate.push() above + function Pause:keypressed(key) + if key == 'p' then + return Gamestate.pop() -- return to previous state + end + end + + +.. function:: Gamestate.(...) + + :param mixed ...: Arguments to pass to the corresponding function. + :returns: The result of the callback function. + +Calls a function on the current gamestate. Can be any function, but is intended +to be one of the :ref:`callbacks`. Mostly useful when not using +:func:`Gamestate.registerEvents`. + +**Example**:: + + function love.draw() + Gamestate.draw() -- is `draw' + end + + function love.update(dt) + Gamestate.update(dt) -- pass dt to currentState:update(dt) + end + + function love.keypressed(key, code) + Gamestate.keypressed(key, code) -- pass multiple arguments + end + + +.. function:: Gamestate.registerEvents([callbacks]) + + :param table callbacks: Names of the callbacks to register. If omitted, + register all love callbacks (optional). + +Overwrite love callbacks to call ``Gamestate.update()``, ``Gamestate.draw()``, +etc. automatically. ``love`` callbacks (e.g. ``love.update()``) are still +invoked as usual. + +This is by done by overwriting the love callbacks, e.g.:: + + local old_update = love.update + function love.update(dt) + old_update(dt) + return Gamestate.current:update(dt) + end + +.. note:: + Only works when called in love.load() or any other function that is + executed *after* the whole file is loaded. + +**Examples**:: + + function love.load() + Gamestate.registerEvents() + Gamestate.switch(menu) + end + + -- love callback will still be invoked + function love.update(dt) + Timer.update(dt) + -- no need for Gamestate.update(dt) + end + +:: + + function love.load() + -- only register draw, update and quit + Gamestate.registerEvents{'draw', 'update', 'quit'} + Gamestate.switch(menu) + end diff --git a/libs/hump/docs/index.rst b/libs/hump/docs/index.rst new file mode 100644 index 0000000..573b65d --- /dev/null +++ b/libs/hump/docs/index.rst @@ -0,0 +1,59 @@ +**hump** - Helper Utilities for a Multitude of Problems +======================================================= + +**hump** is a set of lightweight helpers for the awesome `LÖVE +`_ game framework. +It will help to get you over the initial hump when starting to build a new +game. + +**hump** does nothing that you couldn't do yourself. +But why should you? +You want to write games, not boilerplate! + +**hump**'s components are so loosely coupled that every component is +independent of the others. +You can choose what you need and leave the rest behind. +hump won't judge. + +**hump** just wants to make you happy. + +Read on +------- + +.. toctree:: + :maxdepth: 2 + + hump.gamestate + hump.timer + hump.vector + hump.vector-light + hump.class + hump.signal + hump.camera + license + + +Get hump +-------- + +You can view and download the individual modules on github: `vrld/hump +`_. +You may also download the whole packed sourcecode either in the `zip +`_ or `tar +`_ format. + +Using `Git `_, you can clone the project by running: + + git clone git://github.com/vrld/hump + +Once done, you can check for updates by running + + git pull + + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/libs/hump/docs/license.rst b/libs/hump/docs/license.rst new file mode 100644 index 0000000..06ab3ef --- /dev/null +++ b/libs/hump/docs/license.rst @@ -0,0 +1,26 @@ +License +======= + +Copyright (c) 2011-2018 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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. diff --git a/libs/hump/docs/signal.rst b/libs/hump/docs/signal.rst new file mode 100644 index 0000000..1cbca8b --- /dev/null +++ b/libs/hump/docs/signal.rst @@ -0,0 +1,197 @@ +hump.signal +=========== + +:: + + Signal = require 'hump.signal' + +A simple yet effective implementation of `Signals and Slots +`_, aka the `Observer pattern +`_: Functions can be dynamically +bound to signals. When a *signal* is *emitted*, all registered functions will +be invoked. Simple as that. + +``hump.signal`` makes things a little more interesting by allowing to emit all +signals that match a `Lua string pattern +`_. + +**Example**:: + + -- in AI.lua + Signal.register('shoot', function(x,y, dx,dy) + -- for every critter in the path of the bullet: + -- try to avoid being hit + for critter in pairs(critters) do + if critter:intersectsRay(x,y, dx,dy) then + critter:setMoveDirection(-dy, dx) + end + end + end) + + -- in sounds.lua + Signal.register('shoot', function() + Sounds.fire_bullet:play() + end) + + -- in main.lua + function love.keypressed(key) + if key == ' ' then + local x,y = player.pos:unpack() + local dx,dy = player.direction:unpack() + Signal.emit('shoot', x,y, dx,dy) + end + end + +List of Functions +----------------- + +* :func:`Signal.new() ` +* :func:`Signal.register(s, f) ` +* :func:`Signal.emit(s, ...) ` +* :func:`Signal.remove(s, ...) ` +* :func:`Signal.clear(s) ` +* :func:`Signal.emitPattern(p, ...) ` +* :func:`Signal.removePattern(p, ...) ` +* :func:`Signal.clearPattern(p) ` + +Function Reference +------------------ + +.. function:: Signal.new() + + :returns: A new signal registry. + +Creates a new signal registry that is independent of the default registry: It +will manage its own list of signals and does not in any way affect the the +global registry. Likewise, the global registry does not affect the instance. + +.. note:: + If you don't need multiple independent registries, you can use the + global/default registry (see examples). + +.. note:: + Unlike the default one, signal registry instances use the colon-syntax, + i.e., you need to call ``instance:emit('foo', 23)`` instead of + ``Signal.emit('foo', 23)``. + +**Example**:: + + player.signals = Signal.new() + + +.. function:: Signal.register(s, f) + + :param string s: The signal identifier. + :param function f: The function to register. + :returns: A function handle to use in :func:`Signal.remove()`. + + +Registers a function ``f`` to be called when signal ``s`` is emitted. + +**Examples**:: + + Signal.register('level-complete', function() self.fanfare:play() end) + +:: + + handle = Signal.register('level-load', function(level) level.show_help() end) + +:: + + menu:register('key-left', select_previous_item) + + +.. function:: Signal.emit(s, ...) + + :param string s: The signal identifier. + :param mixed ...: Arguments to pass to the bound functions. (optional) + + +Calls all functions bound to signal ``s`` with the supplied arguments. + + +**Examples**:: + + function love.keypressed(key) + -- using a signal instance + if key == 'left' then menu:emit('key-left') end + end + +:: + + if level.is_finished() then + -- adding arguments + Signal.emit('level-load', level.next_level) + end + + +.. function:: Signal.remove(s, ...) + + :param string s: The signal identifier. + :param functions ...: Functions to unbind from the signal. + + +Unbinds (removes) functions from signal ``s``. + +**Example**:: + + Signal.remove('level-load', handle) + + +.. function:: Signal.clear(s) + + :param string s: The signal identifier. + + +Removes all functions from signal ``s``. + +**Example**:: + + Signal.clear('key-left') + + +.. function:: Signal.emitPattern(p, ...) + + :param string p: The signal identifier pattern. + :param mixed ...: Arguments to pass to the bound functions. (optional) + + +Emits all signals that match a `Lua string pattern +`_. + +**Example**:: + + -- emit all update signals + Signal.emitPattern('^update%-.*', dt) + + +.. function:: Signal.removePattern(p, ...) + + :param string p: The signal identifier pattern. + :param functions ...: Functions to unbind from the signals. + + +Removes functions from all signals that match a `Lua string pattern +`_. + +**Example**:: + + Signal.removePattern('key%-.*', play_click_sound) + + +.. function:: Signal.clearPattern(p) + + :param string p: The signal identifier pattern. + + +Removes **all** functions from all signals that match a `Lua string pattern +`_. + +**Examples**:: + + Signal.clearPattern('sound%-.*') + +:: + + player.signals:clearPattern('.*') -- clear all signals + diff --git a/libs/hump/docs/timer.rst b/libs/hump/docs/timer.rst new file mode 100644 index 0000000..1a54298 --- /dev/null +++ b/libs/hump/docs/timer.rst @@ -0,0 +1,478 @@ +hump.timer +========== + +:: + + Timer = require "hump.timer" + +hump.timer offers a simple interface to schedule the execution of functions. It +is possible to run functions *after* and *for* some amount of time. For +example, a timer could be set to move critters every 5 seconds or to make the +player invincible for a short amount of time. + +In addition to that, ``hump.timer`` offers various `tweening +`_ functions that make it +easier to produce `juicy games `_. + +**Example**:: + + function love.keypressed(key) + if key == ' ' then + Timer.after(1, function() print("Hello, world!") end) + end + end + + function love.update(dt) + Timer.update(dt) + end + +List of Functions +----------------- + +* :func:`Timer.new() ` +* :func:`Timer.after(delay, func) ` +* :func:`Timer.script(func) ` +* :func:`Timer.every(delay, func, count) ` +* :func:`Timer.during(delay, func, after) ` +* :func:`Timer.cancel(handle) ` +* :func:`Timer.clear() ` +* :func:`Timer.update(dt) ` +* :func:`Timer.tween(duration, subject, target, method, after, ...) ` + +Function Reference +------------------ + +.. function:: Timer.new() + + :returns: A timer instance. + + +Creates a new timer instance that is independent of the global timer: It will +manage it's own list of scheduled functions and does not in any way affect the +the global timer. Likewise, the global timer does not affect timer instances. + +.. note:: + If you don't need multiple independent schedulers, you can use the + global/default timer (see examples). + +.. note:: + Unlike the default timer, timer instances use the colon-syntax, i.e., + you need to call ``instance:after(1, foo)`` instead of ``Timer.after(1, + foo)``. + +**Example**:: + + menuTimer = Timer.new() + + +.. function:: Timer.after(delay, func) + + :param number delay: Number of seconds the function will be delayed. + :param function func: The function to be delayed. + :returns: The timer handle. See also :func:`Timer.cancel`. + + +Schedule a function. The function will be executed after ``delay`` seconds have +elapsed, given that ``update(dt)`` is called every frame. + +.. note:: + There is no guarantee that the delay will not be exceeded, it is only + guaranteed that the function will *not* be executed *before* the delay has + passed. + +``func`` will receive itself as only parameter. This is useful to implement +periodic behavior (see the example). + +**Examples**:: + + -- grant the player 5 seconds of immortality + player.isInvincible = true + Timer.after(5, function() player.isInvincible = false end) + +:: + + -- print "foo" every second. See also every() + Timer.after(1, function(func) print("foo") Timer.after(1, func) end) + +:: + + --Using a timer instance: + menuTimer:after(1, finishAnimation) + + +.. function:: Timer.script(func) + + :param function func: Script to execute. + +Execute a function that can be paused without causing the rest of the program to +be suspended. ``func`` will receive a function - ``wait`` - to do interrupt the +script (but not the whole program) as only argument. The function prototype of +wait is: ``wait(delay)``. + +**Examples**:: + + Timer.script(function(wait) + print("Now") + wait(1) + print("After one second") + wait(1) + print("Bye!") + end) + +:: + + -- useful for splash screens + Timer.script(function(wait) + Timer.tween(0.5, splash.pos, {x = 300}, 'in-out-quad') + wait(5) -- show the splash for 5 seconds + Timer.tween(0.5, slpash.pos, {x = 800}, 'in-out-quad') + end) + +:: + + -- repeat something with a varying delay + Timer.script(function(wait) + while true do + spawn_ship() + wait(1 / (1-production_speed)) + end + end) + +:: + + -- jumping with timer.script + self.timers:script(function(wait) + local w = 1/12 + self.jumping = true + Timer.tween(w*2, self, {z = -8}, "out-cubic", function() + Timer.tween(w*2, self, {z = 0},"in-cubic") + end) + + self.quad = self.quads.jump[1] + wait(w) + + self.quad = self.quads.jump[2] + wait(w) + + self.quad = self.quads.jump[3] + wait(w) + + self.quad = self.quads.jump[4] + wait(w) + + self.jumping = false + self.z = 0 + end) + + +.. function:: Timer.every(delay, func, count) + + :param number delay: Number of seconds between two consecutive function calls. + :param function func: The function to be called periodically. + :param number count: Number of times the function is to be called (optional). + :returns: The timer handle. See also :func:`Timer.cancel`. + + +Add a function that will be called ``count`` times every ``delay`` seconds. + +If ``count`` is omitted, the function will be called until it returns ``false`` +or :func:`Timer.cancel` or :func:`Timer.clear` is called on the timer instance. + +**Example**:: + + -- toggle light on and off every second + Timer.every(1, function() lamp:toggleLight() end) + +:: + + -- launch 5 fighters in quick succession (using a timer instance) + mothership_timer:every(0.3, function() self:launchFighter() end, 5) + +:: + + -- flicker player's image as long as he is invincible + Timer.every(0.1, function() + player:flipImage() + return player.isInvincible + end) + + +.. function:: Timer.during(delay, func, after) + + :param number delay: Number of seconds the func will be called. + :param function func: The function to be called on ``update(dt)``. + :param function after: A function to be called after delay seconds (optional). + :returns: The timer handle. See also :func:`Timer.cancel`. + + +Run ``func(dt)`` for the next ``delay`` seconds. The function is called every +time ``update(dt)`` is called. Optionally run ``after()`` once ``delay`` +seconds have passed. + +``after()`` will receive itself as only parameter. + +.. note:: + You should not add new timers in ``func(dt)``, as this can lead to random + crashes. + +**Examples**:: + + -- play an animation for 5 seconds + Timer.during(5, function(dt) animation:update(dt) end) + +:: + + -- shake the camera for one second + local orig_x, orig_y = camera:position() + Timer.during(1, function() + camera:lookAt(orig_x + math.random(-2,2), orig_y + math.random(-2,2)) + end, function() + -- reset camera position + camera:lookAt(orig_x, orig_y) + end) + +:: + + player.isInvincible = true + -- flash player for 3 seconds + local t = 0 + player.timer:during(3, function(dt) + t = t + dt + player.visible = (t % .2) < .1 + end, function() + -- make sure the player is visible after three seconds + player.visible = true + player.isInvincible = false + end) + + +.. function:: Timer.cancel(handle) + + :param table handle: The function to be canceled. + +Prevent a timer from being executed in the future. + +**Examples**:: + + function tick() + print('tick... tock...') + end + handle = Timer.every(1, tick) + -- later + Timer.cancel(handle) -- NOT: Timer.cancel(tick) + +:: + + -- using a timer instance + function tick() + print('tick... tock...') + end + handle = menuTimer:every(1, tick) + -- later + menuTimer:cancel(handle) + + +.. function:: Timer.clear() + +Remove all timed and periodic functions. Functions that have not yet been +executed will discarded. + +**Examples**:: + + Timer.clear() + +:: + + menuTimer:clear() + + +.. function:: Timer.update(dt) + + :param number dt: Time that has passed since the last ``update()``. + +Update timers and execute functions if the deadline is reached. Call in +``love.update(dt)``. + +**Examples**:: + + function love.update(dt) + do_stuff() + Timer.update(dt) + end + +:: + + -- using hump.gamestate and a timer instance + function menuState:update(dt) + self.timers:update(dt) + end + + +.. function:: Timer.tween(duration, subject, target, method, after, ...) + + :param number duration: Duration of the tween. + :param table subject: Object to be tweened. + :param table target: Target values. + :param string method: Tweening method, defaults to 'linear' (:ref:`see here + `, optional). + :param function after: Function to execute after the tween has finished + (optional). + :param mixed ...: Additional arguments to the *tweening* function. + :returns: A timer handle. + + +`Tweening `_ (short for +in-betweening) is the process that happens between two defined states. For +example, a tween can be used to gradually fade out a graphic or move a text +message to the center of the screen. For more information why tweening should +be important to you, check out this great talk on `juicy games +`_. + +``hump.timer`` offers two interfaces for tweening: the low-level +:func:`Timer.during` and the higher level interface :func:`Timer.tween`. + +To see which tweening methods hump offers, :ref:`see below `. + +**Examples**:: + + function love.load() + color = {0, 0, 0} + Timer.tween(10, color, {255, 255, 255}, 'in-out-quad') + end + + function love.update(dt) + Timer.update(dt) + end + + function love.draw() + love.graphics.setBackgroundColor(color) + end + +:: + + function love.load() + circle = {rad = 10, pos = {x = 400, y = 300}} + -- multiple tweens can work on the same subject + -- and nested values can be tweened, too + Timer.tween(5, circle, {rad = 50}, 'in-out-quad') + Timer.tween(2, circle, {pos = {y = 550}}, 'out-bounce') + end + + function love.update(dt) + Timer.update(dt) + end + + function love.draw() + love.graphics.circle('fill', circle.pos.x, circle.pos.y, circle.rad) + end + +:: + + function love.load() + -- repeated tweening + + circle = {rad = 10, x = 100, y = 100} + local grow, shrink, move_down, move_up + grow = function() + Timer.tween(1, circle, {rad = 50}, 'in-out-quad', shrink) + end + shrink = function() + Timer.tween(2, circle, {rad = 10}, 'in-out-quad', grow) + end + + move_down = function() + Timer.tween(3, circle, {x = 700, y = 500}, 'bounce', move_up) + end + move_up = function() + Timer.tween(5, circle, {x = 200, y = 200}, 'out-elastic', move_down) + end + + grow() + move_down() + end + + function love.update(dt) + Timer.update(dt) + end + + function love.draw() + love.graphics.circle('fill', circle.x, circle.y, circle.rad) + end + + + +.. _tweening-methods: + +Tweening methods +---------------- + +At the core of tweening lie interpolation methods. These methods define how the +output should look depending on how much time has passed. For example, consider +the following tween:: + + -- now: player.x = 0, player.y = 0 + Timer.tween(2, player, {x = 2}) + Timer.tween(4, player, {y = 8}) + +At the beginning of the tweens (no time passed), the interpolation method would +place the player at ``x = 0, y = 0``. After one second, the player should be at +``x = 1, y = 2``, and after two seconds the output is ``x = 2, y = 4``. + +The actual duration of and time since starting the tween is not important, only +the fraction of the two. Similarly, the starting value and output are not +important to the interpolation method, since it can be calculated from the +start and end point. Thus an interpolation method can be fully characterized by +a function that takes a number between 0 and 1 and returns a number that +defines the output (usually also between 0 and 1). The interpolation function +must hold that the output is 0 for input 0 and 1 for input 1. + +**hump** predefines several commonly used interpolation methods, which are +generalized versions of `Robert Penner's easing +functions `_. Those are: + +``'linear'``, +``'quad'``, +``'cubic'``, +``'quart'``, +``'quint'``, +``'sine'``, +``'expo'``, +``'circ'``, +``'back'``, +``'bounce'``, and +``'elastic'``. + +It's hard to understand how these functions behave by staring at a graph, so +below are some animation examples. You can change the type of the tween by +changing the selections. + +.. raw:: html + +
+ + + +Note that while the animations above show tweening of shapes, other attributes +(color, opacity, volume of a sound, ...) can be changed as well. + + +Custom interpolators +^^^^^^^^^^^^^^^^^^^^ + +.. warning: + This is a stub + +You can add custom interpolation methods by adding them to the `tween` table:: + + Timer.tween.sqrt = function(t) return math.sqrt(t) end + -- or just Timer.tween.sqrt = math.sqrt + +Access the your method like you would the predefined ones. You can even use the +modyfing prefixes:: + + Timer.tween(5, circle, {radius = 50}, 'in-out-sqrt') + +You can also invert and chain functions:: + + outsqrt = Timer.tween.out(math.sqrt) + inoutsqrt = Timer.tween.chain(math.sqrt, outsqrt) diff --git a/libs/hump/docs/vector-light.rst b/libs/hump/docs/vector-light.rst new file mode 100644 index 0000000..801a092 --- /dev/null +++ b/libs/hump/docs/vector-light.rst @@ -0,0 +1,492 @@ +hump.vector-light +================= + +:: + + vector = require "hump.vector-light" + +An table-free version of :doc:`hump.vector `. Instead of a vector type, +``hump.vector-light`` provides functions that operate on numbers. + +.. note:: + + Using this module instead of :doc:`hump.vector ` may result in + faster code, but does so at the expense of speed of development and code + readability. Unless you are absolutely sure that your code is + significantly slowed down by :doc:`hump.vector `, I recommend using + it instead. + +**Example**:: + + function player:update(dt) + local dx,dy = 0,0 + if love.keyboard.isDown('left') then + dx = -1 + elseif love.keyboard.isDown('right') then + dx = 1 + end + if love.keyboard.isDown('up') then + dy = -1 + elseif love.keyboard.isDown('down') then + dy = 1 + end + dx,dy = vector.normalize(dx, dy) + + player.velx, player.vely = vector.add(player.velx, player.vely, + vector.mul(dy, dx, dy)) + + if vector.len(player.velx, player.vely) > player.max_velocity then + player.velx, player.vely = vector.mul(player.max_velocity, + vector.normalize(player.velx, player.vely) + end + + player.x = player.x + dt * player.velx + player.y = player.y + dt * player.vely + end + +List of Functions +----------------- + +* :func:`vector.str(x,y) ` +* :func:`vector.fromPolar(angle, radius) ` +* :func:`vector.toPolar(x, y) ` +* :func:`vector.randomDirection(len_min, len_max) ` +* :func:`vector.mul(s, x,y) ` +* :func:`vector.div(s, x,y) ` +* :func:`vector.idiv(s, x,y) ` +* :func:`vector.add(x1,y1, x2,y2) ` +* :func:`vector.sub(x1,y1, x2,y2) ` +* :func:`vector.permul(x1,y1, x2,y2) ` +* :func:`vector.dot(x1,y1, x2,y2) ` +* :func:`vector.cross(x1,y1, x2,y2) ` +* :func:`vector.vector.det(x1,y1, x2,y2) ` +* :func:`vector.eq(x1,y1, x2,y2) ` +* :func:`vector.le(x1,y1, x2,y2) ` +* :func:`vector.lt(x1,y1, x2,y2) ` +* :func:`vector.len(x,y) ` +* :func:`vector.len2(x,y) ` +* :func:`vector.dist(x1,y1, x2,y2) ` +* :func:`vector.dist2(x1,y1, x2,y2) ` +* :func:`vector.normalize(x,y) ` +* :func:`vector.rotate(phi, x,y) ` +* :func:`vector.perpendicular(x,y) ` +* :func:`vector.project(x,y, u,v) ` +* :func:`vector.mirror(x,y, u,v) ` +* :func:`vector.angleTo(ox,y, u,v) ` +* :func:`vector.trim(max_length, x,y) ` + +Function Reference +------------------ + +.. function:: vector.str(x,y) + + :param numbers x,y: The vector. + :returns: The string representation. + + +Produce a human-readable string of the form ``(x,y)``. +Useful for debugging. + +**Example**:: + + print(vector.str(love.mouse.getPosition())) + + +.. function:: vector.fromPolar(angle, radius) + + :param number angle: Angle of the vector in radians. + :param number radius: Length of the vector (optional, default = 1). + :returns: ``x``, ``y``: The vector in cartesian coordinates. + + +Convert polar coordinates to cartesian coordinates. +The ``angle`` is measured against the vector (1,0), i.e., the x axis. + +**Examples**:: + + x,y = vector.polar(math.pi,10) + + +.. function:: vector.toPolar(x, y) + + :param numbers x,y: A vector. + :returns: ``angle``, ``radius``: The vector in polar coordinates. + +Convert the vector to polar coordinates, i.e., the angle and the radius/lenth. + +**Example**:: + + -- complex multiplication + phase1, abs1 = vector.toPolar(re1, im1) + phase2, abs2 = vector.toPolar(re2, im2) + + vector.fromPolar(phase1+phase2, abs1*abs2) + +.. function:: vector.randomDirection(len_min, len_max) + + :param number len_min: Minimum length of the vector (optional, default = 1). + :param number len_max: Maximum length of the vector (optional, default = ``len_min``). + :returns: ``x``, ``y``: A vector pointing in a random direction with a random length between ``len_min`` and ``len_max``. + +Sample a vector with random direction and (optional) length. + +**Examples**:: + + x,y = vector.randomDirection() -- length is 1 + x,y = vector.randomDirection(1,5) -- length is a random value between 1 and 5 + x,y = vector.randomDirection(100) -- length is 100 + + +.. function:: vector.mul(s, x,y) + + :param number s: A scalar. + :param numbers x,y: A vector. + :returns: ``x*s, y*s``. + + +Computes ``x*s,y*s``. The order of arguments is chosen so that it's possible to +chain operations (see example). + +**Example**:: + + velx,vely = vec.mul(dt, vec.add(velx,vely, accx,accy)) + + +.. function:: vector.div(s, x,y) + + :param number s: A scalar. + :param numbers x,y: A vector. + :returns: ``x/s, y/s``. + + +Computes ``x/s,y/s``. The order of arguments is chosen so that it's possible to +chain operations (see example). + +**Example**:: + + x,y = vec.div(self.zoom, vec.sub(x,y, w/2,h/2)) + x,y = vec.div(self.zoom, x-w/2, y-h/2) + + +.. function:: vector.idiv(s, x,y) + + :param number s: A scalar. + :param numbers x,y: A vector. + :returns: ``x//s, y//s``. + + +Computes integer division ``x//s,y//s`` (only Lua 5.3 and up). The order of +arguments is chosen so that it's possible to chain operations (see example). + +**Example**:: + + i,k = vec.idiv(grid.cellsize, x,y) + i,k = vec.idiv(grid.cellsize, love.mouse.getPosition()) + + +.. function:: vector.add(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: ``x1+x2, x1+x2``. + + +Computes the sum \\((x1+x2, y1+y2)\\)`` of two vectors. Meant to be used in +conjunction with other functions like :func:`vector.mul`. + +**Example**:: + + player.x,player.y = vector.add(player.x,player.y, vector.mul(dt, dx,dy)) + + +.. function:: vector.sub(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: ``x1-x2, x1-x2``. + + +Computes the difference \\((x1-x2, y1-y2)\\) of two vectors. Meant to be used in +conjunction with other functions like :func:`vector.mul`. + +**Example**:: + + dx,dy = vector.sub(400,300, love.mouse.getPosition()) + + +.. function:: vector.permul(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: ``x1*x2, y1*y2``. + + +Component-wise multiplication, i.e.: ``x1*x2, y1*y2``. + +**Example**:: + + x,y = vector.permul(x,y, 1,1.5) + + +.. function:: vector.dot(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: ``x1*x2 + y1*y2``. + + +Computes the `dot product `_ of two +vectors: ``x1*x2 + y1*y2``. + +**Example**:: + + cosphi = vector.dot(rx,ry, vx,vy) + + +.. function:: vector.cross(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: ``x1*y2 - y1*x2``. + + +Computes the `cross product `_ of +two vectors: ``x1*y2 - y1*x2``. + +**Example**:: + + parallelogram_area = vector.cross(ax,ay, bx,by) + + +.. function:: vector.vector.det(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: ``x1*y2 - y1*x2``. + + +Alias to :func:`vector.cross`. + +**Example**:: + + parallelogram_area = vector.det(ax,ay, bx,by) + + +.. function:: vector.eq(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: ``x1 == x2 and y1 == y2`` + +Test for equality. + +**Example**:: + + if vector.eq(x1,y1, x2,y2) then be.happy() end + + +.. function:: vector.le(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: ``x1 <= x2 and y1 <= y2``. + +Test for partial lexicographical order, ``<=``. + +**Example**:: + + if vector.le(x1,y1, x2,y2) then be.happy() end + + +.. function:: vector.lt(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: ``x1 < x2 or (x1 == x2) and y1 <= y2``. + + +Test for strict lexicographical order, ``<``. + +**Example**:: + + if vector.lt(x1,y1, x2,y2) then be.happy() end + + +.. function:: vector.len(x,y) + + :param numbers x,y: The vector. + :returns: Length of the vector. + +Get length of a vector, i.e. ``math.sqrt(x*x + y*y)``. + +**Example**:: + + distance = vector.len(love.mouse.getPosition()) + + +.. function:: vector.len2(x,y) + + :param numbers x,y: The vector. + :returns: Squared length of the vector. + +Get squared length of a vector, i.e. ``x*x + y*y``. + +**Example**:: + + -- get closest vertex to a given vector + closest, dsq = vertices[1], vector.len2(px-vertices[1].x, py-vertices[1].y) + for i = 2,#vertices do + local temp = vector.len2(px-vertices[i].x, py-vertices[i].y) + if temp < dsq then + closest, dsq = vertices[i], temp + end + end + + +.. function:: vector.dist(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: The distance of the points. + + +Get distance of two points. The same as ``vector.len(x1-x2, y1-y2)``. + +**Example**:: + + -- get closest vertex to a given vector + -- slightly slower than the example using len2() + closest, dist = vertices[1], vector.dist(px,py, vertices[1].x,vertices[1].y) + for i = 2,#vertices do + local temp = vector.dist(px,py, vertices[i].x,vertices[i].y) + if temp < dist then + closest, dist = vertices[i], temp + end + end + + +.. function:: vector.dist2(x1,y1, x2,y2) + + :param numbers x1,y1: First vector. + :param numbers x2,y2: Second vector. + :returns: The squared distance of two points. + +Get squared distance of two points. The same as ``vector.len2(x1-x2, y1-y2)``. + +**Example**:: + + -- get closest vertex to a given vector + closest, dsq = vertices[1], vector.dist2(px,py, vertices[1].x,vertices[1].y) + for i = 2,#vertices do + local temp = vector.dist2(px,py, vertices[i].x,vertices[i].y) + if temp < dsq then + closest, dsq = vertices[i], temp + end + end + + +.. function:: vector.normalize(x,y) + + :param numbers x,y: The vector. + :returns: Vector with same direction as the input vector, but length 1. + + +Get normalized vector, i.e. a vector with the same direction as the input +vector, but with length 1. + +**Example**:: + + dx,dy = vector.normalize(vx,vy) + + +.. function:: vector.rotate(phi, x,y) + + :param number phi: Rotation angle in radians. + :param numbers x,y: The vector. + :returns: The rotated vector + + +Get a rotated vector. + +**Example**:: + + -- approximate a circle + circle = {} + for i = 1,30 do + local phi = 2 * math.pi * i / 30 + circle[i*2-1], circle[i*2] = vector.rotate(phi, 0,1) + end + + +.. function:: vector.perpendicular(x,y) + + :param numbers x,y: The vector. + :returns: A vector perpendicular to the input vector + + +Quick rotation by 90°. The same (but faster) as ``vector.rotate(math.pi/2, x,y)``. + +**Example**:: + + nx,ny = vector.normalize(vector.perpendicular(bx-ax, by-ay)) + + +.. function:: vector.project(x,y, u,v) + + :param numbers x,y: The vector to project. + :param numbers u,v: The vector to project onto. + :returns: The projected vector. + + +Project vector onto another vector. + +**Example**:: + + vx_p,vy_p = vector.project(vx,vy, ax,ay) + + +.. function:: vector.mirror(x,y, u,v) + + :param numbers x,y: The vector to mirror. + :param numbers u,v: The vector defining the axis. + :returns: The mirrored vector. + + +Mirrors vector on the axis defined by the other vector. + +**Example**:: + + vx,vy = vector.mirror(vx,vy, surface.x,surface.y) + + +.. function:: vector.angleTo(ox,y, u,v) + + :param numbers x,y: Vector to measure the angle. + :param numbers u,v (optional): Reference vector. + :returns: Angle in radians. + + +Measures the angle between two vectors. ``u`` and ``v`` default to ``0`` if omitted, +i.e. the function returns the angle to the coordinate system. + +**Example**:: + + lean = vector.angleTo(self.upx, self.upy, 0,1) + if lean > .1 then self:fallOver() end + + +.. function:: vector.trim(max_length, x,y) + + :param number max_length: Maximum allowed length of the vector. + :param numbers x,y: Vector to trim. + :returns: The trimmed vector. + +Trim the vector to ``max_length``, i.e. return a vector that points in the same +direction as the source vector, but has a magnitude smaller or equal to +``max_length``. + +**Example**:: + + vel_x, vel_y = vector.trim(299792458, + vector.add(vel_x, vel_y, + vector.mul(mass * dt, force_x, force_y))) diff --git a/libs/hump/docs/vector.rst b/libs/hump/docs/vector.rst new file mode 100644 index 0000000..c1a709c --- /dev/null +++ b/libs/hump/docs/vector.rst @@ -0,0 +1,492 @@ +hump.vector +=========== + +:: + + vector = require "hump.vector" + +A handy 2D vector class providing most of the things you do with vectors. + +You can access the individual coordinates by ``vec.x`` and ``vec.y``. + +.. note:: + + The vectors are stored as tables. Most operations create new vectors and + thus new tables, which *may* put the garbage collector under stress. + If you experience slowdowns that are caused by hump.vector, try the + table-less version :doc:`hump.vector-light `. + +**Example**:: + + function player:update(dt) + local delta = vector(0,0) + if love.keyboard.isDown('left') then + delta.x = -1 + elseif love.keyboard.isDown('right') then + delta.x = 1 + end + if love.keyboard.isDown('up') then + delta.y = -1 + elseif love.keyboard.isDown('down') then + delta.y = 1 + end + delta:normalizeInplace() + + player.velocity = player.velocity + delta * player.acceleration * dt + + if player.velocity:len() > player.max_velocity then + player.velocity = player.velocity:normalized() * player.max_velocity + end + + player.position = player.position + player.velocity * dt + end + +List of Functions +----------------- + +* :func:`vector.new(x,y) ` +* :func:`vector.fromPolar(angle, radius) ` +* :func:`vector.randomDirection(len_min, len_max) ` +* :func:`vector.isvector(v) ` +* :func:`vector:clone() ` +* :func:`vector:unpack() ` +* :func:`vector:permul(other) ` +* :func:`vector:len() ` +* :func:`vector:toPolar() ` +* :func:`vector:len2() ` +* :func:`vector:dist(other) ` +* :func:`vector:dist2(other) ` +* :func:`vector:normalized() ` +* :func:`vector:normalizeInplace() ` +* :func:`vector:rotated(angle) ` +* :func:`vector:rotateInplace(angle) ` +* :func:`vector:perpendicular() ` +* :func:`vector:projectOn(v) ` +* :func:`vector:mirrorOn(v) ` +* :func:`vector:cross(other) ` +* :func:`vector:angleTo(other) ` +* :func:`vector:trimmed(max_length) ` +* :func:`vector:trimInplace(max_length) ` + + +Vector arithmetic +----------------- + +**hump** provides vector arithmetic by implement the corresponding metamethods +(``__add``, ``__mul``, etc.). Here are the semantics: + +``vector + vector = vector`` + Component wise sum: \\((a,b) + (x,y) = (a+x, b+y)\\) +``vector - vector = vector`` + Component wise difference: \\((a,b) - (x,y) = (a-x, b-y)\\) +``vector * vector = number`` + Dot product: \\((a,b) \\cdot (x,y) = a\\cdot x + b\\cdot y\\) +``number * vector = vector`` + Scalar multiplication/scaling: \\((a,b) \\cdot s = (s\\cdot a, s\\cdot b)\\) +``vector * number = vector`` + Scalar multiplication/scaling: \\(s \\cdot (x,y) = (s\\cdot x, s\\cdot y)\\) +``vector / number = vector`` + Scalar division: \\((a,b) / s = (a/s, b/s)\\). +``vector // number = vector`` + Scalar integer division (only Lua 5.3 and up): \\((a,b) // s = (a//s, b//s)\\). + +Common relations are also defined: + +``a == b`` + Same as ``a.x == b.x and a.y == b.y``. +``a <= b`` + Same as ``a.x <= b.x and a.y <= b.y``. +``a < b`` + Lexicographical order: ``a.x < b.x or (a.x == b.x and a.y < b.y)``. + +**Example**:: + + -- acceleration, player.velocity and player.position are vectors + acceleration = vector(0,-9) + player.velocity = player.velocity + acceleration * dt + player.position = player.position + player.velocity * dt + + +Function Reference +------------------ + +.. function:: vector.new(x,y) + + :param numbers x,y: Coordinates. + :returns: The vector. + + +Create a new vector. + +**Examples**:: + + a = vector.new(10,10) + +:: + + -- as a shortcut, you can call the module like a function: + vector = require "hump.vector" + a = vector(10,10) + + +.. function:: vector.fromPolar(angle, radius) + + :param number angle: Angle of the vector in radians. + :param number radius: Length of the vector (optional, default = 1). + :returns: The vector in cartesian coordinates. + + +Create a new vector from polar coordinates. +The ``angle`` is measured against the vector (1,0), i.e., the x axis. + +**Examples**:: + + a = vector.polar(math.pi,10) + +.. function:: vector.randomDirection(len_min, len_max) + + :param number len_min: Minimum length of the vector (optional, default = 1). + :param number len_max: Maximum length of the vector (optional, default = ``len_min``). + :returns: A vector pointing in a random direction with a random length between ``len_min`` and ``len_max``. + +**Examples**:: + + rnd = vector.randomDirection() -- length is 1 + rnd = vector.randomDirection(100) -- length is 100 + rnd = vector.randomDirection(1,5) -- length is a random value between 1 and 5 + +Sample a vector with random direction and (optional) length. + +.. function:: vector.isvector(v) + + :param mixed v: The variable to test. + :returns: ``true`` if ``v`` is a vector, ``false`` otherwise. + +Test whether a variable is a vector. + +**Example**:: + + if not vector.isvector(v) then + v = vector(v,0) + end + + +.. function:: vector:clone() + + :returns: Copy of the vector. + +Copy a vector. Assigning a vector to a variable will create a *reference*, so +when modifying the vector referenced by the new variable would also change the +old one:: + + a = vector(1,1) -- create vector + b = a -- b references a + c = a:clone() -- c is a copy of a + b.x = 0 -- changes a,b and c + print(a,b,c) -- prints '(1,0), (1,0), (1,1)' + +**Example**:: + + copy = original:clone() + + +.. function:: vector:unpack() + + :returns: The coordinates ``x,y``. + + +Extract coordinates. + +**Examples**:: + + x,y = pos:unpack() + +:: + + love.graphics.draw(self.image, self.pos:unpack()) + + +.. function:: vector:permul(other) + + :param vector other: The second source vector. + :returns: Vector whose components are products of the source vectors. + + +Multiplies vectors coordinate wise, i.e. ``result = vector(a.x * b.x, a.y * +b.y)``. + +Does not change either argument vectors, but creates a new one. + +**Example**:: + + -- scale with different magnitudes + scaled = original:permul(vector(1,1.5)) + + +.. function:: vector:len() + + :returns: Length of the vector. + + +Get length of the vector, i.e. ``math.sqrt(vec.x * vec.x + vec.y * vec.y)``. + +**Example**:: + + distance = (a - b):len() + + +.. function:: vector:toPolar() + + :returns: The vector in polar coordinates (angle, radius). + +Convert the vector to polar coordinates, i.e., the angle and the radius/lenth. + +**Example**:: + + -- complex multiplication + p, q = a:toPolar(), b:toPolar() + c = vector(p.x+q.x, p.y*q.y) + + +.. function:: vector:len2() + + :returns: Squared length of the vector. + + +Get squared length of the vector, i.e. ``vec.x * vec.x + vec.y * vec.y``. + +**Example**:: + + -- get closest vertex to a given vector + closest, dsq = vertices[1], (pos - vertices[1]):len2() + for i = 2,#vertices do + local temp = (pos - vertices[i]):len2() + if temp < dsq then + closest, dsq = vertices[i], temp + end + end + + +.. function:: vector:dist(other) + + :param vector other: Other vector to measure the distance to. + :returns: The distance of the vectors. + + +Get distance of two vectors. The same as ``(a - b):len()``. + +**Example**:: + + -- get closest vertex to a given vector + -- slightly slower than the example using len2() + closest, dist = vertices[1], pos:dist(vertices[1]) + for i = 2,#vertices do + local temp = pos:dist(vertices[i]) + if temp < dist then + closest, dist = vertices[i], temp + end + end + + +.. function:: vector:dist2(other) + + :param vector other: Other vector to measure the distance to. + :returns: The squared distance of the vectors. + + +Get squared distance of two vectors. The same as ``(a - b):len2()``. + +**Example**:: + + -- get closest vertex to a given vector + -- slightly faster than the example using len2() + closest, dsq = vertices[1], pos:dist2(vertices[1]) + for i = 2,#vertices do + local temp = pos:dist2(vertices[i]) + if temp < dsq then + closest, dsq = vertices[i], temp + end + end + + +.. function:: vector:normalized() + + :returns: Vector with same direction as the input vector, but length 1. + + +Get normalized vector: a vector with the same direction as the input vector, +but with length 1. + +Does not change the input vector, but creates a new vector. + +**Example**:: + + direction = velocity:normalized() + + +.. function:: vector:normalizeInplace() + + :returns: Itself -- the normalized vector + + +Normalize a vector, i.e. make the vector to have length 1. Great to use on +intermediate results. + +.. warning:: + This modifies the vector. If in doubt, use :func:`vector:normalized()`. + +**Example**:: + + normal = (b - a):perpendicular():normalizeInplace() + + +.. function:: vector:rotated(angle) + + :param number angle: Rotation angle in radians. + :returns: The rotated vector + + +Get a vector with same length, but rotated by ``angle``: + +.. image:: _static/vector-rotated.png + :alt: Sketch of rotated vector. + +Does not change the input vector, but creates a new vector. + +**Example**:: + + -- approximate a circle + circle = {} + for i = 1,30 do + local phi = 2 * math.pi * i / 30 + circle[#circle+1] = vector(0,1):rotated(phi) + end + +.. function:: vector:rotateInplace(angle) + + :param number angle: Rotation angle in radians. + :returns: Itself -- the rotated vector + + +Rotate a vector in-place. Great to use on intermediate results. + +.. warning:: + This modifies the vector. If in doubt, use :func:`vector:rotated()`. + +**Example**:: + + -- ongoing rotation + spawner.direction:rotateInplace(dt) + + +.. function:: vector:perpendicular() + + :returns: A vector perpendicular to the input vector + +Quick rotation by 90°. Creates a new vector. The same (but faster) as +``vec:rotate(math.pi/2)``: + +.. image:: _static/vector-perpendicular.png + :alt: Sketch of two perpendicular vectors + +**Example**:: + + normal = (b - a):perpendicular():normalizeInplace() + + + +.. function:: vector:projectOn(v) + + :param vector v: The vector to project on. + :returns: ``vector`` The projected vector. + + +Project vector onto another vector: + +.. image:: _static/vector-projectOn.png + :alt: Sketch of vector projection. + +**Example**:: + + velocity_component = velocity:projectOn(axis) + + + +.. function:: vector:mirrorOn(v) + + :param vector v: The vector to mirror on. + :returns: The mirrored vector. + + +Mirrors vector on the axis defined by the other vector: + +.. image: _static/vector-mirrorOn.png + :alt: Sketch of a vector mirrored on another vector + +**Example**:: + + deflected_velocity = ball.velocity:mirrorOn(surface_normal) + + +.. function:: vector:cross(other) + + :param vector other: Vector to compute the cross product with. + :returns: ``number`` Cross product of both vectors. + + +Get cross product of two vectors. Equals the area of the parallelogram spanned +by both vectors. + +**Example**:: + + parallelogram_area = a:cross(b) + + +.. function:: vector:angleTo(other) + + :param vector other: Vector to measure the angle to (optional). + :returns: Angle in radians. + + +Measures the angle between two vectors. If ``other`` is omitted it defaults +to the vector ``(0,0)``, i.e. the function returns the angle to the coordinate +system. + +**Example**:: + + lean = self.upvector:angleTo(vector(0,1)) + if lean > .1 then self:fallOver() end + +.. function:: vector:trimmed(max_length) + + :param number max_length: Maximum allowed length of the vector. + :returns: A trimmed vector. + +Trim the vector to ``max_length``, i.e. return a vector that points in the same +direction as the source vector, but has a magnitude smaller or equal to +``max_length``. + +Does not change the input vector, but creates a new vector. + +**Example**:: + + ship.velocity = ship.force * ship.mass * dt + ship.velocity = ship.velocity:trimmed(299792458) + + +.. function:: vector:trimInplace(max_length) + + :param number max_length: Maximum allowed length of the vector. + :returns: Itself -- the trimmed vector. + +Trim the vector to ``max_length``, i.e. return a vector that points in the same +direction as the source vector, but has a magnitude smaller or equal to +``max_length``. + +.. warning:: + This modifies the vector. If in doubt, use :func:`vector:trimmed()`. + + +**Example**:: + + ship.velocity = (ship.velocity + ship.force * ship.mass * dt):trimInplace(299792458) diff --git a/libs/hump/gamestate.lua b/libs/hump/gamestate.lua new file mode 100644 index 0000000..e3e78c3 --- /dev/null +++ b/libs/hump/gamestate.lua @@ -0,0 +1,113 @@ +--[[ +Copyright (c) 2010-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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 function __NULL__() end + + -- default gamestate produces error on every callback +local state_init = setmetatable({leave = __NULL__}, + {__index = function() error("Gamestate not initialized. Use Gamestate.switch()") end}) +local stack = {state_init} +local initialized_states = setmetatable({}, {__mode = "k"}) +local state_is_dirty = true + +local GS = {} +function GS.new(t) return t or {} end -- constructor - deprecated! + +local function change_state(stack_offset, to, ...) + local pre = stack[#stack] + + -- initialize only on first call + ;(initialized_states[to] or to.init or __NULL__)(to) + initialized_states[to] = __NULL__ + + stack[#stack+stack_offset] = to + state_is_dirty = true + return (to.enter or __NULL__)(to, pre, ...) +end + +function GS.switch(to, ...) + assert(to, "Missing argument: Gamestate to switch to") + assert(to ~= GS, "Can't call switch with colon operator") + ;(stack[#stack].leave or __NULL__)(stack[#stack]) + return change_state(0, to, ...) +end + +function GS.push(to, ...) + assert(to, "Missing argument: Gamestate to switch to") + assert(to ~= GS, "Can't call push with colon operator") + return change_state(1, to, ...) +end + +function GS.pop(...) + assert(#stack > 1, "No more states to pop!") + local pre, to = stack[#stack], stack[#stack-1] + stack[#stack] = nil + ;(pre.leave or __NULL__)(pre) + state_is_dirty = true + return (to.resume or __NULL__)(to, pre, ...) +end + +function GS.current() + return stack[#stack] +end + +-- XXX: don't overwrite love.errorhandler by default: +-- this callback is different than the other callbacks +-- (see http://love2d.org/wiki/love.errorhandler) +-- overwriting thi callback can result in random crashes (issue #95) +local all_callbacks = { 'draw', 'update' } + +-- fetch event callbacks from love.handlers +for k in pairs(love.handlers) do + all_callbacks[#all_callbacks+1] = k +end + +function GS.registerEvents(callbacks) + local registry = {} + callbacks = callbacks or all_callbacks + for _, f in ipairs(callbacks) do + registry[f] = love[f] or __NULL__ + love[f] = function(...) + registry[f](...) + return GS[f](...) + end + end +end + +-- forward any undefined functions +setmetatable(GS, {__index = function(_, func) + -- call function only if at least one 'update' was called beforehand + -- (see issue #46) + if not state_is_dirty or func == 'update' then + state_is_dirty = false + return function(...) + return (stack[#stack][func] or __NULL__)(stack[#stack], ...) + end + end + return __NULL__ +end}) + +return GS diff --git a/libs/hump/hump-0.4-2.rockspec b/libs/hump/hump-0.4-2.rockspec new file mode 100644 index 0000000..fd76d09 --- /dev/null +++ b/libs/hump/hump-0.4-2.rockspec @@ -0,0 +1,33 @@ +package = "hump" +version = "0.4-2" +source = { + url = "git://github.com/vrld/hump" +} +description = { + summary = "Lightweight game development utilities", + detailed = [[Collection of independent components that implement common task needed in games: + - Gamestates that can stack on each other (e.g., for menus) + - Timers and Tweens with thread-like scripting support + - Cameras with camera movement control (locking, smooth follow, etc) + - 2D vector math + - Signals and Slots + - Prototype-based OOP helper + ]], + homepage = "https://hump.readthedocs.io", + license = "MIT", +} +dependencies = { + "lua >= 5.1" +} +build = { + type = "builtin", + modules = { + ["hump.camera"] = "camera.lua", + ["hump.class"] = "class.lua", + ["hump.gamestate"] = "gamestate.lua", + ["hump.signal"] = "signal.lua", + ["hump.timer"] = "timer.lua", + ["hump.vector"] = "vector.lua", + ["hump.vector-light"] = "vector-light.lua" + }, +} diff --git a/libs/hump/signal.lua b/libs/hump/signal.lua new file mode 100644 index 0000000..e204ca0 --- /dev/null +++ b/libs/hump/signal.lua @@ -0,0 +1,102 @@ +--[[ +Copyright (c) 2012-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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 Registry = {} +Registry.__index = function(self, key) + return Registry[key] or (function() + local t = {} + rawset(self, key, t) + return t + end)() +end + +function Registry:register(s, f) + self[s][f] = f + return f +end + +function Registry:emit(s, ...) + for f in pairs(self[s]) do + f(...) + end +end + +function Registry:remove(s, ...) + local f = {...} + for i = 1,select('#', ...) do + self[s][f[i]] = nil + end +end + +function Registry:clear(...) + local s = {...} + for i = 1,select('#', ...) do + self[s[i]] = {} + end +end + +function Registry:emitPattern(p, ...) + for s in pairs(self) do + if s:match(p) then self:emit(s, ...) end + end +end + +function Registry:registerPattern(p, f) + for s in pairs(self) do + if s:match(p) then self:register(s, f) end + end + return f +end + +function Registry:removePattern(p, ...) + for s in pairs(self) do + if s:match(p) then self:remove(s, ...) end + end +end + +function Registry:clearPattern(p) + for s in pairs(self) do + if s:match(p) then self[s] = {} end + end +end + +-- instancing +function Registry.new() + return setmetatable({}, Registry) +end + +-- default instance +local default = Registry.new() + +-- module forwards calls to default instance +local module = {} +for k in pairs(Registry) do + if k ~= "__index" then + module[k] = function(...) return default[k](default, ...) end + end +end + +return setmetatable(module, {__call = Registry.new}) diff --git a/libs/hump/spec/timer_spec.lua b/libs/hump/spec/timer_spec.lua new file mode 100644 index 0000000..9a7c2ef --- /dev/null +++ b/libs/hump/spec/timer_spec.lua @@ -0,0 +1,73 @@ +local timer = require 'timer'() + +describe('hump.timer', function() + it('runs a function during a specified time', function() + local delta, remaining + + timer:during(10, function(...) delta, remaining = ... end) + + timer:update(2) + assert.are.equal(delta, 2) + assert.are.equal(8, remaining) + + timer:update(5) + assert.are.equal(delta, 5) + assert.are.equal(3, remaining) + + timer:update(10) + assert.are.equal(delta, 10) + assert.are.equal(0, remaining) + end) + + it('runs a function after a specified time', function() + local finished1 = false + local finished2 = false + + timer:after(3, function(...) finished1 = true end) + timer:after(5, function(...) finished2 = true end) + + timer:update(4) + assert.are.equal(true, finished1) + assert.are.equal(false, finished2) + + timer:update(4) + assert.are.equal(true, finished1) + assert.are.equal(true, finished2) + end) + + it('runs a function every so often', function() + local count = 0 + + timer:every(1, function(...) count = count + 1 end) + + timer:update(3) + assert.are.equal(3, count) + + timer:update(7) + assert.are.equal(10, count) + end) + + it('can script timed events', function() + local state + + timer:script(function(wait) + state = 'foo' + wait(1) + state = 'bar' + end) + + assert.are.equal('foo', state) + timer:update(0.5) + assert.are.equal('foo', state) + timer:update(1) + assert.are.equal('bar', state) + end) + + it('cancels and clears timer functions', function() + pending('to be tested...') + end) + + it('tweens', function() + pending('to be tested...') + end) +end) diff --git a/libs/hump/timer.lua b/libs/hump/timer.lua new file mode 100644 index 0000000..8315f68 --- /dev/null +++ b/libs/hump/timer.lua @@ -0,0 +1,215 @@ +--[[ +Copyright (c) 2010-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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 Timer = {} +Timer.__index = Timer + +local function _nothing_() end + +local function updateTimerHandle(handle, dt) + -- handle: { + -- time = , + -- after = , + -- during = , + -- limit = , + -- count = , + -- } + handle.time = handle.time + dt + handle.during(dt, math.max(handle.limit - handle.time, 0)) + + while handle.time >= handle.limit and handle.count > 0 do + if handle.after(handle.after) == false then + handle.count = 0 + break + end + handle.time = handle.time - handle.limit + handle.count = handle.count - 1 + end +end + +function Timer:update(dt) + -- timers may create new timers, which leads to undefined behavior + -- in pairs() - so we need to put them in a different table first + local to_update = {} + for handle in pairs(self.functions) do + to_update[handle] = handle + end + + for handle in pairs(to_update) do + if self.functions[handle] then + updateTimerHandle(handle, dt) + if handle.count == 0 then + self.functions[handle] = nil + end + end + end +end + +function Timer:during(delay, during, after) + local handle = { time = 0, during = during, after = after or _nothing_, limit = delay, count = 1 } + self.functions[handle] = true + return handle +end + +function Timer:after(delay, func) + return self:during(delay, _nothing_, func) +end + +function Timer:every(delay, after, count) + local count = count or math.huge -- exploit below: math.huge - 1 = math.huge + local handle = { time = 0, during = _nothing_, after = after, limit = delay, count = count } + self.functions[handle] = true + return handle +end + +function Timer:cancel(handle) + self.functions[handle] = nil +end + +function Timer:clear() + self.functions = {} +end + +function Timer:script(f) + local co = coroutine.wrap(f) + co(function(t) + self:after(t, co) + coroutine.yield() + end) +end + +Timer.tween = setmetatable({ + -- helper functions + out = function(f) -- 'rotates' a function + return function(s, ...) return 1 - f(1-s, ...) end + end, + chain = function(f1, f2) -- concatenates two functions + return function(s, ...) return (s < .5 and f1(2*s, ...) or 1 + f2(2*s-1, ...)) * .5 end + end, + + -- useful tweening functions + linear = function(s) return s end, + quad = function(s) return s*s end, + cubic = function(s) return s*s*s end, + quart = function(s) return s*s*s*s end, + quint = function(s) return s*s*s*s*s end, + sine = function(s) return 1-math.cos(s*math.pi/2) end, + expo = function(s) return 2^(10*(s-1)) end, + circ = function(s) return 1 - math.sqrt(1-s*s) end, + + back = function(s,bounciness) + bounciness = bounciness or 1.70158 + return s*s*((bounciness+1)*s - bounciness) + end, + + bounce = function(s) -- magic numbers ahead + local a,b = 7.5625, 1/2.75 + return math.min(a*s^2, a*(s-1.5*b)^2 + .75, a*(s-2.25*b)^2 + .9375, a*(s-2.625*b)^2 + .984375) + end, + + elastic = function(s, amp, period) + amp, period = amp and math.max(1, amp) or 1, period or .3 + return (-amp * math.sin(2*math.pi/period * (s-1) - math.asin(1/amp))) * 2^(10*(s-1)) + end, +}, { + +-- register new tween +__call = function(tween, self, len, subject, target, method, after, ...) + -- recursively collects fields that are defined in both subject and target into a flat list + local function tween_collect_payload(subject, target, out) + for k,v in pairs(target) do + local ref = subject[k] + assert(type(v) == type(ref), 'Type mismatch in field "'..k..'".') + if type(v) == 'table' then + tween_collect_payload(ref, v, out) + else + local ok, delta = pcall(function() return (v-ref)*1 end) + assert(ok, 'Field "'..k..'" does not support arithmetic operations') + out[#out+1] = {subject, k, delta} + end + end + return out + end + + method = tween[method or 'linear'] -- see __index + local payload, t, args = tween_collect_payload(subject, target, {}), 0, {...} + + local last_s = 0 + return self:during(len, function(dt) + t = t + dt + local s = method(math.min(1, t/len), unpack(args)) + local ds = s - last_s + last_s = s + for _, info in ipairs(payload) do + local ref, key, delta = unpack(info) + ref[key] = ref[key] + delta * ds + end + end, after) +end, + +-- fetches function and generated compositions for method `key` +__index = function(tweens, key) + if type(key) == 'function' then return key end + + assert(type(key) == 'string', 'Method must be function or string.') + if rawget(tweens, key) then return rawget(tweens, key) end + + local function construct(pattern, f) + local method = rawget(tweens, key:match(pattern)) + if method then return f(method) end + return nil + end + + local out, chain = rawget(tweens,'out'), rawget(tweens,'chain') + return construct('^in%-([^-]+)$', function(...) return ... end) + or construct('^out%-([^-]+)$', out) + or construct('^in%-out%-([^-]+)$', function(f) return chain(f, out(f)) end) + or construct('^out%-in%-([^-]+)$', function(f) return chain(out(f), f) end) + or error('Unknown interpolation method: ' .. key) +end}) + +-- Timer instancing +function Timer.new() + return setmetatable({functions = {}, tween = Timer.tween}, Timer) +end + +-- default instance +local default = Timer.new() + +-- module forwards calls to default instance +local module = {} +for k in pairs(Timer) do + if k ~= "__index" then + module[k] = function(...) return default[k](default, ...) end + end +end +module.tween = setmetatable({}, { + __index = Timer.tween, + __newindex = function(k,v) Timer.tween[k] = v end, + __call = function(t, ...) return default:tween(...) end, +}) + +return setmetatable(module, {__call = Timer.new}) diff --git a/libs/hump/vector-light.lua b/libs/hump/vector-light.lua new file mode 100644 index 0000000..6fb20d2 --- /dev/null +++ b/libs/hump/vector-light.lua @@ -0,0 +1,186 @@ +--[[ +Copyright (c) 2012-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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 sqrt, cos, sin, atan2 = math.sqrt, math.cos, math.sin, math.atan2 + +local function str(x,y) + return "("..tonumber(x)..","..tonumber(y)..")" +end + +local function mul(s, x,y) + return s*x, s*y +end + +local function div(s, x,y) + return x/s, y/s +end + +local function add(x1,y1, x2,y2) + return x1+x2, y1+y2 +end + +local function sub(x1,y1, x2,y2) + return x1-x2, y1-y2 +end + +local function permul(x1,y1, x2,y2) + return x1*x2, y1*y2 +end + +local function dot(x1,y1, x2,y2) + return x1*x2 + y1*y2 +end + +local function det(x1,y1, x2,y2) + return x1*y2 - y1*x2 +end + +local function eq(x1,y1, x2,y2) + return x1 == x2 and y1 == y2 +end + +local function lt(x1,y1, x2,y2) + return x1 < x2 or (x1 == x2 and y1 < y2) +end + +local function le(x1,y1, x2,y2) + return x1 <= x2 and y1 <= y2 +end + +local function len2(x,y) + return x*x + y*y +end + +local function len(x,y) + return sqrt(x*x + y*y) +end + +local function fromPolar(angle, radius) + radius = radius or 1 + return cos(angle)*radius, sin(angle)*radius +end + +local function randomDirection(len_min, len_max) + len_min = len_min or 1 + len_max = len_max or len_min + + assert(len_max > 0, "len_max must be greater than zero") + assert(len_max >= len_min, "len_max must be greater than or equal to len_min") + + return fromPolar(math.random()*2*math.pi, + math.random() * (len_max-len_min) + len_min) +end + +local function toPolar(x, y) + return atan2(y,x), len(x,y) +end + +local function dist2(x1,y1, x2,y2) + return len2(x1-x2, y1-y2) +end + +local function dist(x1,y1, x2,y2) + return len(x1-x2, y1-y2) +end + +local function normalize(x,y) + local l = len(x,y) + if l > 0 then + return x/l, y/l + end + return x,y +end + +local function rotate(phi, x,y) + local c, s = cos(phi), sin(phi) + return c*x - s*y, s*x + c*y +end + +local function perpendicular(x,y) + return -y, x +end + +local function project(x,y, u,v) + local s = (x*u + y*v) / (u*u + v*v) + return s*u, s*v +end + +local function mirror(x,y, u,v) + local s = 2 * (x*u + y*v) / (u*u + v*v) + return s*u - x, s*v - y +end + +-- ref.: http://blog.signalsondisplay.com/?p=336 +local function trim(maxLen, x, y) + local s = maxLen * maxLen / len2(x, y) + s = s > 1 and 1 or math.sqrt(s) + return x * s, y * s +end + +local function angleTo(x,y, u,v) + if u and v then + return atan2(y, x) - atan2(v, u) + end + return atan2(y, x) +end + +-- the module +return { + str = str, + + fromPolar = fromPolar, + toPolar = toPolar, + randomDirection = randomDirection, + + -- arithmetic + mul = mul, + div = div, + idiv = idiv, + add = add, + sub = sub, + permul = permul, + dot = dot, + det = det, + cross = det, + + -- relation + eq = eq, + lt = lt, + le = le, + + -- misc operations + len2 = len2, + len = len, + dist2 = dist2, + dist = dist, + normalize = normalize, + rotate = rotate, + perpendicular = perpendicular, + project = project, + mirror = mirror, + trim = trim, + angleTo = angleTo, +} diff --git a/libs/hump/vector.lua b/libs/hump/vector.lua new file mode 100644 index 0000000..2d75d62 --- /dev/null +++ b/libs/hump/vector.lua @@ -0,0 +1,218 @@ +--[[ +Copyright (c) 2010-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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 assert = assert +local sqrt, cos, sin, atan2 = math.sqrt, math.cos, math.sin, math.atan2 + +local vector = {} +vector.__index = vector + +local function new(x,y) + return setmetatable({x = x or 0, y = y or 0}, vector) +end +local zero = new(0,0) + +local function fromPolar(angle, radius) + radius = radius or 1 + return new(cos(angle) * radius, sin(angle) * radius) +end + +local function randomDirection(len_min, len_max) + len_min = len_min or 1 + len_max = len_max or len_min + + assert(len_max > 0, "len_max must be greater than zero") + assert(len_max >= len_min, "len_max must be greater than or equal to len_min") + + return fromPolar(math.random() * 2*math.pi, + math.random() * (len_max-len_min) + len_min) +end + +local function isvector(v) + return type(v) == 'table' and type(v.x) == 'number' and type(v.y) == 'number' +end + +function vector:clone() + return new(self.x, self.y) +end + +function vector:unpack() + return self.x, self.y +end + +function vector:__tostring() + return "("..tonumber(self.x)..","..tonumber(self.y)..")" +end + +function vector.__unm(a) + return new(-a.x, -a.y) +end + +function vector.__add(a,b) + assert(isvector(a) and isvector(b), "Add: wrong argument types ( expected)") + return new(a.x+b.x, a.y+b.y) +end + +function vector.__sub(a,b) + assert(isvector(a) and isvector(b), "Sub: wrong argument types ( expected)") + return new(a.x-b.x, a.y-b.y) +end + +function vector.__mul(a,b) + if type(a) == "number" then + return new(a*b.x, a*b.y) + elseif type(b) == "number" then + return new(b*a.x, b*a.y) + else + assert(isvector(a) and isvector(b), "Mul: wrong argument types ( or expected)") + return a.x*b.x + a.y*b.y + end +end + +function vector.__div(a,b) + assert(isvector(a) and type(b) == "number", "wrong argument types (expected / )") + return new(a.x / b, a.y / b) +end + +function vector.__eq(a,b) + return a.x == b.x and a.y == b.y +end + +function vector.__lt(a,b) + return a.x < b.x or (a.x == b.x and a.y < b.y) +end + +function vector.__le(a,b) + return a.x <= b.x and a.y <= b.y +end + +function vector.permul(a,b) + assert(isvector(a) and isvector(b), "permul: wrong argument types ( expected)") + return new(a.x*b.x, a.y*b.y) +end + +function vector:toPolar() + return new(atan2(self.x, self.y), self:len()) +end + +function vector:len2() + return self.x * self.x + self.y * self.y +end + +function vector:len() + return sqrt(self.x * self.x + self.y * self.y) +end + +function vector.dist(a, b) + assert(isvector(a) and isvector(b), "dist: wrong argument types ( expected)") + local dx = a.x - b.x + local dy = a.y - b.y + return sqrt(dx * dx + dy * dy) +end + +function vector.dist2(a, b) + assert(isvector(a) and isvector(b), "dist: wrong argument types ( expected)") + local dx = a.x - b.x + local dy = a.y - b.y + return (dx * dx + dy * dy) +end + +function vector:normalizeInplace() + local l = self:len() + if l > 0 then + self.x, self.y = self.x / l, self.y / l + end + return self +end + +function vector:normalized() + return self:clone():normalizeInplace() +end + +function vector:rotateInplace(phi) + local c, s = cos(phi), sin(phi) + self.x, self.y = c * self.x - s * self.y, s * self.x + c * self.y + return self +end + +function vector:rotated(phi) + local c, s = cos(phi), sin(phi) + return new(c * self.x - s * self.y, s * self.x + c * self.y) +end + +function vector:perpendicular() + return new(-self.y, self.x) +end + +function vector:projectOn(v) + assert(isvector(v), "invalid argument: cannot project vector on " .. type(v)) + -- (self * v) * v / v:len2() + local s = (self.x * v.x + self.y * v.y) / (v.x * v.x + v.y * v.y) + return new(s * v.x, s * v.y) +end + +function vector:mirrorOn(v) + assert(isvector(v), "invalid argument: cannot mirror vector on " .. type(v)) + -- 2 * self:projectOn(v) - self + local s = 2 * (self.x * v.x + self.y * v.y) / (v.x * v.x + v.y * v.y) + return new(s * v.x - self.x, s * v.y - self.y) +end + +function vector:cross(v) + assert(isvector(v), "cross: wrong argument types ( expected)") + return self.x * v.y - self.y * v.x +end + +-- ref.: http://blog.signalsondisplay.com/?p=336 +function vector:trimInplace(maxLen) + local s = maxLen * maxLen / self:len2() + s = (s > 1 and 1) or math.sqrt(s) + self.x, self.y = self.x * s, self.y * s + return self +end + +function vector:angleTo(other) + if other then + return atan2(self.y, self.x) - atan2(other.y, other.x) + end + return atan2(self.y, self.x) +end + +function vector:trimmed(maxLen) + return self:clone():trimInplace(maxLen) +end + + +-- the module +return setmetatable({ + new = new, + fromPolar = fromPolar, + randomDirection = randomDirection, + isvector = isvector, + zero = zero +}, { + __call = function(_, ...) return new(...) end +}) diff --git a/libs/json4lua b/libs/json4lua deleted file mode 160000 index a0da807..0000000 --- a/libs/json4lua +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a0da807dca77baf07d287631f5ad41a9097fc25c diff --git a/libs/json4lua/README.md b/libs/json4lua/README.md new file mode 100644 index 0000000..b4f8015 --- /dev/null +++ b/libs/json4lua/README.md @@ -0,0 +1,52 @@ +# json4lua +JSON and JSONRPC for Lua + +# Installation # +``` +luarocks install --server=http://rocks.moonscript.org/manifests/amrhassan --local json4Lua +``` + +# JSON Usage # + +## Encoding ## + +```lua +json = require('json') +print(json.encode({ 1, 2, 'fred', {first='mars',second='venus',third='earth'} })) +``` +```json +[1,2,"fred", {"first":"mars","second":"venus","third":"earth"}] +``` + +## Decoding ## + +```lua +json = require("json") +testString = [[ { "one":1 , "two":2, "primes":[2,3,5,7] } ]] +decoded = json.decode(testString) +table.foreach(decoded, print) +print ("Primes are:") +table.foreach(decoded.primes,print) +``` +``` +one 1 +two 2 +primes table: 0032B928 +Primes are: +1 2 +2 3 +3 5 +4 7 +``` + +# JSONRPC Usage # +```lua +json = require('json') +require("json.rpc") +server = json.rpc.proxy("http://jsolait.net/testj.py") +result, error = server.echo('Test echo!') +print(result) +``` +``` +Test echo! +``` diff --git a/libs/json4lua/doc/INSTALL.txt b/libs/json4lua/doc/INSTALL.txt new file mode 100755 index 0000000..3e7cad3 --- /dev/null +++ b/libs/json4lua/doc/INSTALL.txt @@ -0,0 +1,4 @@ +INSTALLATION +============ + +See INSTALLATION section in doc/index.html \ No newline at end of file diff --git a/libs/json4lua/doc/LICENCE.txt b/libs/json4lua/doc/LICENCE.txt new file mode 100755 index 0000000..48f61e7 --- /dev/null +++ b/libs/json4lua/doc/LICENCE.txt @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2009 Craig Mason-Jones + +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. \ No newline at end of file diff --git a/libs/json4lua/doc/README.txt b/libs/json4lua/doc/README.txt new file mode 100755 index 0000000..59e5b7b --- /dev/null +++ b/libs/json4lua/doc/README.txt @@ -0,0 +1,5 @@ +=================================================================================================================== +== README.txt +=================================================================================================================== + +Please see doc/index.html diff --git a/libs/json4lua/doc/VERSION.txt b/libs/json4lua/doc/VERSION.txt new file mode 100755 index 0000000..deae881 --- /dev/null +++ b/libs/json4lua/doc/VERSION.txt @@ -0,0 +1,4 @@ +JSON4Lua and JSONRPC4Lua +Version 1.0.0 +4 March 2015 +http://github.com/craigmj/json4lua/ \ No newline at end of file diff --git a/libs/json4lua/doc/cgilua_patch.html b/libs/json4lua/doc/cgilua_patch.html new file mode 100755 index 0000000..1b4d6b3 --- /dev/null +++ b/libs/json4lua/doc/cgilua_patch.html @@ -0,0 +1,187 @@ + + +JSON4Lua and JSONRPC4Lua + + + + + + + + + + + +
+

+ + +

+ + + + + + + +
+
Patching CGILua to handle text/plain
+ + +JSON RPC (both the JSONRPC4Lua implementation and the jsolait Javascript implementation) send the http request with a Content-Type of text/plain.

+ +CGILua 5.0 does not accept text/plain content, and will generate an error of 'Unsupported Media Type: text/plain'.

+ +This is easily patched in CGILua 5.0 by making the following change to cgilua/post.lua, line 286:

+Change:

+	elseif strfind (contenttype, "text/xml") then
+
+to +
+	elseif strfind (contenttype, "text/xml") or strfind (contenttype, "text/plain") then
+
+This makes CGILua handle text/plain as it does text/xml, without parsing the incoming POST data.

+ +Please note: I have requested the maintainers of CGILua to make this change to CGILua, whereafter this patch will no longer be required. + + +

+ + \ No newline at end of file diff --git a/libs/json4lua/doc/index.html b/libs/json4lua/doc/index.html new file mode 100755 index 0000000..12ec6c7 --- /dev/null +++ b/libs/json4lua/doc/index.html @@ -0,0 +1,394 @@ + + +JSON4Lua and JSONRPC4Lua + + + + + + + + + + + +
+

+ + +

+ + + + + + + +
+
JSON4Lua and JSONRPC4Lua
+ +

Latest News

+

(2009-08-06) We've changed the JSON4Lua and JSONRPC4Lua licence from the GPL to the MIT licence, like Lua itself.

+

+ The 0.9.20 release fixes a bug in Lua 5.1 operation, introduces a json.null value to force null values in JSON encodings, improves performance (over 50% faster on some tests), and permits /* comments */ in the JSON string being decoded. +

+ + +

Introduction

+ JSON4Lua and JSONRPC4Lua implement JSON (JavaScript Object Notation) encoding and decoding and a JSON-RPC-over-http client for Lua.

+ +JSON is JavaScript Object Notation, a simple encoding of Javascript-like objects that is ideal for lightweight transmission of relatively weakly-typed data. +A sub-package of JSON4Lua is JSONRPC4Lua, which provides a simple JSON-RPC-over-http client and server (in a CGILua environment) for Lua. Please seen the documentation below for JSONRPC4Lua. + + +

Licence

+ JSON4Lua is licensed under the MIT Consortium licence like Lua itself. Please see LICENCE.txt for details.

+ + +

Requirements

+ JSON4Lua is a pure-Lua module that is Lua 5.0 compatible (if you have compat-5.1 for Lua 5.0). JSON4Lua also works (perfectly, I hope) under Lua 5.1, which is where I largely use it nowadays. Since Lua is platform independent, so is JSON4Lua.

+ + The JSON4RPC sub-module requires Lua Socket 2.0. It uses socket.http for for the RPC over http connection. Socket 2.0 includes ltn12, which is also used by JSON4RPC.

+ + To use json.rpcserver you need a CGILua enabled webserver. However, a quick patch is required in CGILua 5.0 to support JSON-RPC. + + +

Download

+ JSON4Lua is hosted on LuaForge. + + + + + + + + + + + + + + + +
VersionDateNotes
0.9.306 August 2009 + Changed to MIT Licence. +
0.9.204 January 2006 + Introduction of local Lua functions for private functions (removed _ function prefix).
+ Fixed Lua 5.1 compatibility issues.
+ Introduced json.null to have null values in associative arrays.
+ Performance improvement (more than 50% on some tests) through table.concat rather than .. operator.
+ json.decode now ignores /* */ comments in the JSON string.
+
0.9.1020 December 2005 + Fixes bug with array representation when nil / null values occur in the array.
+ Adds content-type header of text/plain to JSON RPC http requests.
+ Introduces json.rpcserver module with simple JSON RPC enablement for Lua objects.
+ Moved the json.lua file into the json directory. Ensure, therefore, that your LUA_PATH contains a module-finding form like LUA_PATH = c:\proj\lua\?\?.lua;?.lua.
+
0.9.0119 December 2005Minor corrections to documentation.
0.9.0019 December 2005First release
+ + + +

Installation

+ As of version 0.9.10, all the JSON4Lua files are contained in the json subdirectory in the distribution zip.

+ Simply copy the json subdirectory so that it is in your Lua path.

+ Ensure that your LUA_PATH variable permits module resolution of the form ?/?.lua.

+ Example +

+
+

Using Windows

+ Under Windows, set your Lua path as (my Lua installation is in c:\proj\lua\):

+ + set LUA_PATH=c:\proj\lua\?.lua;c:\proj\lua\?\?.lua;?.lua +

+ For compat-5.1.lua to start when Lua starts (if you're using Lua 5.0), you also need:

+ + set LUA_INIT=@c:\proj\lua\compat-5.1.lua +

+ You probably also want to set your library path:

+ set LUA_CPATH=c:\proj\lua\lib\?.dll;?.dll + + +

Usage & Reference

+ The following functions in JSON4Lua and JSONRPC4Lua are of interest:

+

+
string json.encode( lua_object )
+
Returns the Lua object JSON encoded into a string.

+ Example +

+ + json = require("json")
+ print (json.encode( { 1, 2, 'fred', {first='mars',second='venus',third='earth'} } )) +

+ prints

[1,2,"fred", {"first":"mars","second":"venus","third","earth"}] +

+ +
+
lua_object json.decode( json_string )
+
Decodes the JSON encoded data structure, and returns a Lua object with the appropriate data.

+ Example + +

+ + json = require("json")
+ + testString = [[ { "one":1 , "two":2, "primes":[2,3,5,7] } ]]
+ o = json.decode(testString)
+ table.foreach(o,print)
+ print ("Primes are:")
+ table.foreach(o.primes,print) +

+ prints:

+

+one		1
+two		2
+primes		table: 0032B928
+Primes are:
+1		2
+2		3
+3		5
+4		7
+
+ +
json.null
+
Returns a unique value that will be encoded as a null in a JSON encoding. +

This is necessary in one situation. In Lua, if a key in a table has a nil value, the key is simply discarded (since any non-existent key has a nil value). The encoding of arrays has been built to manage this nil-values in arrays, but associative arrays provide a problem. Consider: +

t = { user="test", password=nil }
+ Since Lua simply discards the password key, JSON4Lua encodes this as the JSON string +
{"user":"test"}
+ If, for some reason, your JSON RPC Server requires a defined null value, use the following code: +
t = { user="test", password=json.null }
+ This will now correctly encode to: +
{"user":"test","password":null}
+ Incidentally, json.null is simply a function that returns itself, so that you can use either json.null or json.null() as you fancy. +
+ + +
result, error json.rpc.call ( url, method, ...)
+
Calls the named method on the given url with the arg parameters. Returns the result and the error. If error is nil, no error occurred.

+ Example +

+
require ("json.rpc")
+result, error = json.rpc.call("http://jsolait.net/testj.py","echo","Test echo!")
+print(result)
+

prints

+

Test echo!
+
+
+
proxyServer = json.rpc.proxy (url)
+
Creates a proxy server object on which JSON-RPC calls can be made. Each call will return the result, error. If error is nil, no error occurred.

+ Example +

+
+require ("json.rpc")
+server = json.rpc.proxy("http://jsolait.net/testj.py")
+result, error  = server.echo('Test echo!')
+print(result)
+

prints

+

Test echo!
+
+
+ +
json.rpcserver.serve(object[, packReturn])
+
+ Handles an incoming CGILua request as a JSON RPC request and serves the request from + the given object. +

The optional packReturn parameter, if set true, will, if the requested + method returns more than one value, pack these returned values into an array. If only a single value + is returned, it is not packed into an array. If packReturn is false (or not set), only the first + return value from the requested method will be returned. This is necessitated since the JSON protocol does not permit a method call to return more than a single value. +

+ + serve returns nothing.

+ + Example +

+
+--
+-- jsonrpc.lua
+-- Installed in a CGILua webserver environment (with necessary CGI Lua 5.0 patch)
+--
+require ('json.rpcserver')
+
+-- The Lua class that is to serve JSON RPC requests
+local myServer = {
+  echo = function (msg) return msg end,
+  average = function(...)
+    local total=0
+    local count=0
+    for i=1, table.getn(arg) do
+      total = total + arg[i]
+      count = count + 1
+    end
+    return { average= total/count, sum = total, n=count }
+  end
+}
+
+json.rpcserver.serve(myServer)
+
+An example of using this JSON RPC server from a Lua file: +
+require ('json.rpc')
+local server = json.rpc.proxy('http://www.myserver.com/jsonrpc.lua')
+table.foreach(server.average(10,15,23), print)
+
+Prints: +
+average	16
+sum	48
+n	3
+
+
+ +
+
+ + +

History & Roadmap

+ The downloads sections details the versions and their related histories. I will wait for Lua 5.1 to be final (expected late January) before making the JSON4Lua module beta. If no serious bugs or objections are encountered, I will make the module 1.0 on 1 April 2006 (to coincide with April Fool's day!) +
+ + \ No newline at end of file diff --git a/libs/json4lua/doc/pics/json4lua.gif b/libs/json4lua/doc/pics/json4lua.gif new file mode 100755 index 0000000..938bcb9 Binary files /dev/null and b/libs/json4lua/doc/pics/json4lua.gif differ diff --git a/libs/json4lua/doc/pics/lunartone.gif b/libs/json4lua/doc/pics/lunartone.gif new file mode 100755 index 0000000..e914a8f Binary files /dev/null and b/libs/json4lua/doc/pics/lunartone.gif differ diff --git a/libs/json4lua/examples/example.lua b/libs/json4lua/examples/example.lua new file mode 100755 index 0000000..36497da --- /dev/null +++ b/libs/json4lua/examples/example.lua @@ -0,0 +1,23 @@ +--[[ +JSON4Lua example script. +Demonstrates the simple functionality of the json module. +]]-- +json = require('json') + + +-- Object to JSON encode +test = { + one='first',two='second',three={2,3,5} +} + +jsonTest = json.encode(test) + +print('JSON encoded test is: ' .. jsonTest) + +-- Now JSON decode the json string +result = json.decode(jsonTest) + +print ("The decoded table result:") +table.foreach(result,print) +print ("The decoded table result.three") +table.foreach(result.three, print) diff --git a/libs/json4lua/examples/jsonrpc.lua b/libs/json4lua/examples/jsonrpc.lua new file mode 100755 index 0000000..f265b8a --- /dev/null +++ b/libs/json4lua/examples/jsonrpc.lua @@ -0,0 +1,21 @@ +-- +-- jsonrpc.lua +-- Installed in a CGILua webserver environment (with necessary CGI Lua 5.0 patch) +-- +require ('json.rpcserver') + +-- The Lua class that is to serve JSON RPC requests +local myServer = { + echo = function (msg) return msg end, + average = function(...) + local total=0 + local count=0 + for i=1, table.getn(arg) do + total = total + arg[i] + count = count + 1 + end + return { average= total/count, sum = total, n=count } + end +} + +json.rpcserver.serve(myServer) \ No newline at end of file diff --git a/libs/json4lua/examples/tests.lua b/libs/json4lua/examples/tests.lua new file mode 100755 index 0000000..2e58b6e --- /dev/null +++ b/libs/json4lua/examples/tests.lua @@ -0,0 +1,223 @@ +--[[ +Some basic tests for JSON4Lua. +]]-- + +--- Compares two tables for being data-identical. +function compareData(a,b) + if (type(a)=='string' or type(a)=='number' or type(a)=='boolean' or type(a)=='nil') then return a==b end + -- After basic data types, we're only interested in tables + if (type(a)~='table') then return true end + -- Check that a has everything b has + for k,v in pairs(b) do + if (not compareData( a[k], v ) ) then return false end + end + for k,v in pairs(a) do + if (not compareData( v, b[k] ) ) then return false end + end + return true +end + +--- +-- Checks that our compareData function works properly +function testCompareData() + s = "name" + r = "name" + assert(compareData(s,r)) + assert(not compareData('fred',s)) + assert(not compareData(nil, s)) + assert(not compareData("123",123)) + assert(not compareData(false, nil)) + assert(compareData(true, true)) + assert(compareData({1,2,3},{1,2,3})) + assert(compareData({'one',2,'three'},{'one',2,'three'})) + assert(not compareData({'one',2,4},{4,2,'one'})) + assert(compareData({one='ichi',two='nichi',three='san'}, {three='san',two='nichi',one='ichi'})) + s = { one={1,2,3}, two={one='hitotsu',two='futatsu',three='mitsu'} } + assert(compareData(s,s)) + t = { one={1,2,3}, two={one='een',two='twee',three='drie'} } + assert(not compareData(s,t)) +end + +testCompareData() + +-- +-- +-- Performs some perfunctory tests on JSON module +function testJSON4Lua() + json = require('json') + + if nil then + -- Test encodeString + s = [["\" +]] + r = json._encodeString(s) + assert(r=='\\"\\\\\\"\\n') + s = [["""\\\"]] + r = json._encodeString(s) + assert(r==[[\"\"\"\\\\\\\"]]) + + end + + -- Test encode for basic strings (complicated strings) + s = [[Hello, Lua!]] + r = json.encode(s) + assert(r=='"Hello, Lua!"') + s = [["\" +]] + r = json.encode(s) + assert(r=='\"\\"\\\\\\"\\n\"') + s = [["""\\\"]] + r = json.encode(s) + assert(r==[["\"\"\"\\\\\\\""]]) + + -- Test encode for numeric values + s = 23 + r = json.encode(s) + assert(r=='23') + s=48.123 + r = json.encode(s) + assert(r=='48.123') + + -- Test encode for boolean values + assert(json.encode(true)=='true') + assert(json.encode(false)=='false') + assert(json.encode(nil)=='null') + + -- Test encode for arrays + s = {1,2,3} + r = json.encode(s) + assert(r=="[1,2,3]") + s = {9,9,9} + r = json.encode(s) + assert(r=="[9,9,9]") + + -- Complex array test + s = { 2, 'joe', false, nil, 'hi' } + r = json.encode(s) + assert(r=='[2,"joe",false,null,"hi"]') + + -- Test encode for tables + s = {Name='Craig',email='craig@lateral.co.za',age=35} + r = json.encode(s) + -- NB: This test can fail because of order: need to test further once + -- decoding is supported. + -- assert(r==[[{"age":35,"Name":"Craig","email":"craig@lateral.co.za"}]]) + + -- Test encoding tables with numeric (string) indexes + s = {} + s['1']='One' + r = json.encode(s) + -- print("r = ", r) + assert(r=='{"1":"One"}') + + s['2']= {One='Uno'} + r = json.encode(s) + assert(compareData(json.decode(r), s)) + + -- Test decode_scanWhitespace + if nil then + s = " \n \r \t " + e = json._decode_scanWhitespace(s,1) + assert(e==string.len(s)+1) + s = " \n\r\t4" + assert(json._decode_scanWhitespace(s,1)==5) + + -- Test decode_scanString + s = [["Test"]] + r,e = json._decode_scanString(s,1) + assert(r=='Test' and e==7) + s = [["This\nis a \"test"]] + r = json._decode_scanString(s,1) + assert(r=="This\nis a \"test") + + s = [["Test\u00A7\\"]] + r,e = json._decode_scanString(s,1) + assert(r=="Test\xC2\xA7\\" and e==9) + + -- Test decode_scanNumber + s = [[354]] + r,e = json._decode_scanNumber(s,1) + assert(r==354 and e==4) + s = [[ 4565.23 AND OTHER THINGS ]] + r,e = json._decode_scanNumber(s,2) + assert(r==4565.23 and e==9) + s = [[ -23.22 and ]] + r,e = json._decode_scanNumber(s,2) + assert(r==-23.22 and e==8) + + -- Test decode_scanConstant + s = "true" + r,e = json._decode_scanConstant(s,1) + assert(r==true and e==5) + s = " false " + r,e = json._decode_scanConstant(s,3) + assert(r==false and e==8) + s = "1null6" + r,e = json._decode_scanConstant(s,2) + assert(r==nil and e==6) + + -- Test decode_scanArray + s = "[1,2,3]" + r,e = json._decode_scanArray(s,1) + assert(compareData(r,{1,2,3})) + s = [[[ 1 , 3 ,5 , "Fred" , true, false, null, -23 ] ]] + r,e = json._decode_scanArray(s,1) + assert(compareData(r, {1,3,5,'Fred',true,false,nil,-23} ) ) + s = "[3,5,null,7,9]" + r,e = json._decode_scanArray(s,1) + assert(compareData(r, {3,5,nil,7,9})) + s = "[3,5,null,7,9,null,null]" + r,e = json._decode_scanArray(s,1) + assert(compareData(r, {3,5,nil,7,9,nil,nil})) + + end + + s = [["Test\u00A7\\\""]] + r,e = json.decode(s) + assert(r=="Test\xC2\xA7\\\"", r) + + -- Test decode_scanObject + s = [[ {"one":1, "two":2, "three":"three", "four":true} ]] + r,e = json.decode(s) + -- for x,y in pairs(r) do + -- print(x,y) + -- end + assert(compareData(r,{one=1,two=2,three='three',four=true})) + s = [[ { "one" : { "first":1,"second":2,"third":3}, "two":2, "three":false } ]] + r,e = json.decode(s) + assert(compareData(r, {one={first=1,second=2,third=3},two=2,three=false})) + s = [[ { "primes" : [2,3,5,7,9], "user":{"name":"craig","age":35,"programs_lua":true}, + "lua_is_great":true } ]] + r,e = json.decode(s) + assert(compareData(r, {primes={2,3,5,7,9},user={name='craig',age=35,programs_lua=true},lua_is_great=true})) + + -- Test json.null management + t = { 1,2,json.null,4 } + assert( json.encode(t)=="[1,2,null,4]" ) + t = {x=json.null } + r = json.encode(t) + assert( json.encode(t) == '{"x":null}' ) + + -- Test comment decoding + s = [[ /* A comment + that spans + a few lines + */ + "test" + ]] + r,e = json.decode(s) + assert(r=='test',"Comment decoding failed") + + -- Per error reported by M.Hund, with incorrect decoding of string-numbered tables + s = {} + subt = {a="a",b="b",c="c"} + s['1'] = subt + s['2'] = subt + s['3'] = subt + r = json.decode('{"1":{"a":"a","b":"b","c":"c"},"2":{"a":"a","b":"b","c":"c"},"3":{"a":"a","b":"b","c":"c"}}') + assert(compareData(s, r)) +end + +testJSON4Lua() + +print("JSON4Lua tests completed successfully") diff --git a/libs/json4lua/examples/timetrials.lua b/libs/json4lua/examples/timetrials.lua new file mode 100755 index 0000000..cbda514 --- /dev/null +++ b/libs/json4lua/examples/timetrials.lua @@ -0,0 +1,46 @@ +--[[ + Some Time Trails for the JSON4Lua package +]]-- + + +require('json') +require('os') +require('table') + +local t1 = os.clock() +local jstr +local v +for i=1,100 do + local t = {} + for j=1,500 do + table.insert(t,j) + end + for j=1,500 do + table.insert(t,"VALUE") + end + jstr = json.encode(t) + v = json.decode(jstr) + --print(json.encode(t)) +end + +for i = 1,100 do + local t = {} + for j=1,500 do + local m= math.mod(j,3) + if (m==0) then + t['a'..j] = true + elseif m==1 then + t['a'..j] = json.null + else + t['a'..j] = j + end + end + jstr = json.encode(t) + v = json.decode(jstr) +end + +print (jstr) +--print(type(t1)) +local t2 = os.clock() + +print ("Elapsed time=" .. os.difftime(t2,t1) .. "s") \ No newline at end of file diff --git a/libs/json4lua/json/json.lua b/libs/json4lua/json/json.lua new file mode 100755 index 0000000..87e3663 --- /dev/null +++ b/libs/json4lua/json/json.lua @@ -0,0 +1,442 @@ +----------------------------------------------------------------------------- +-- JSON4Lua: JSON encoding / decoding support for the Lua language. +-- json Module. +-- Author: Craig Mason-Jones +-- Homepage: http://github.com/craigmj/json4lua/ +-- Version: 1.0.1 +-- This module is released under the MIT License (MIT). +-- Please see LICENCE.txt for details. +-- +-- USAGE: +-- This module exposes two functions: +-- json.encode(o) +-- Returns the table / string / boolean / number / nil / json.null value as a JSON-encoded string. +-- json.decode(json_string) +-- Returns a Lua object populated with the data encoded in the JSON string json_string. +-- +-- REQUIREMENTS: +-- compat-5.1 if using Lua 5.0 +-- +-- CHANGELOG +-- 1.0.1 Introduced set_plugin_info call to register plugin details for Wireshark. Per +-- suggestion by T. Lerman. +-- 0.9.20 Introduction of local Lua functions for private functions (removed _ function prefix). +-- Fixed Lua 5.1 compatibility issues. +-- Introduced json.null to have null values in associative arrays. +-- json.encode() performance improvement (more than 50%) through table.concat rather than .. +-- Introduced decode ability to ignore /**/ comments in the JSON string. +-- 0.9.10 Fix to array encoding / decoding to correctly manage nil/null values in arrays. +----------------------------------------------------------------------------- + +----------------------------------------------------------------------------- +-- Imports and dependencies +----------------------------------------------------------------------------- +local math = require('math') +local string = require("string") +local table = require("table") + +----------------------------------------------------------------------------- +-- Module declaration +----------------------------------------------------------------------------- +local json = {} -- Public namespace +local json_private = {} -- Private namespace + +-- Public constants +json.EMPTY_ARRAY={} +json.EMPTY_OBJECT={} + +-- Public functions + +-- Private functions +local decode_scanArray +local decode_scanComment +local decode_scanConstant +local decode_scanNumber +local decode_scanObject +local decode_scanString +local decode_scanWhitespace +local encodeString +local isArray +local isEncodable + +----------------------------------------------------------------------------- +-- PUBLIC FUNCTIONS +----------------------------------------------------------------------------- +--- Encodes an arbitrary Lua object / variable. +-- @param v The Lua object / variable to be JSON encoded. +-- @return String containing the JSON encoding in internal Lua string format (i.e. not unicode) +function json.encode (v) + -- Handle nil values + if v==nil then + return "null" + end + + local vtype = type(v) + + -- Handle strings + if vtype=='string' then + return '"' .. json_private.encodeString(v) .. '"' -- Need to handle encoding in string + end + + -- Handle booleans + if vtype=='number' or vtype=='boolean' then + return tostring(v) + end + + -- Handle tables + if vtype=='table' then + local rval = {} + -- Consider arrays separately + local bArray, maxCount = isArray(v) + if bArray then + for i = 1,maxCount do + table.insert(rval, json.encode(v[i])) + end + else -- An object, not an array + for i,j in pairs(v) do + if isEncodable(i) and isEncodable(j) then + table.insert(rval, '"' .. json_private.encodeString(i) .. '":' .. json.encode(j)) + end + end + end + if bArray then + return '[' .. table.concat(rval,',') ..']' + else + return '{' .. table.concat(rval,',') .. '}' + end + end + + -- Handle null values + if vtype=='function' and v==json.null then + return 'null' + end + + assert(false,'encode attempt to encode unsupported type ' .. vtype .. ':' .. tostring(v)) +end + + +--- Decodes a JSON string and returns the decoded value as a Lua data structure / value. +-- @param s The string to scan. +-- @param [startPos] Optional starting position where the JSON string is located. Defaults to 1. +-- @param Lua object, number The object that was scanned, as a Lua table / string / number / boolean or nil, +-- and the position of the first character after +-- the scanned JSON object. +function json.decode(s, startPos) + startPos = startPos and startPos or 1 + startPos = decode_scanWhitespace(s,startPos) + assert(startPos<=string.len(s), 'Unterminated JSON encoded object found at position in [' .. s .. ']') + local curChar = string.sub(s,startPos,startPos) + -- Object + if curChar=='{' then + return decode_scanObject(s,startPos) + end + -- Array + if curChar=='[' then + return decode_scanArray(s,startPos) + end + -- Number + if string.find("+-0123456789.e", curChar, 1, true) then + return decode_scanNumber(s,startPos) + end + -- String + if curChar==[["]] or curChar==[[']] then + return decode_scanString(s,startPos) + end + if string.sub(s,startPos,startPos+1)=='/*' then + return json.decode(s, decode_scanComment(s,startPos)) + end + -- Otherwise, it must be a constant + return decode_scanConstant(s,startPos) +end + +--- The null function allows one to specify a null value in an associative array (which is otherwise +-- discarded if you set the value with 'nil' in Lua. Simply set t = { first=json.null } +function json.null() + return json.null -- so json.null() will also return null ;-) +end +----------------------------------------------------------------------------- +-- Internal, PRIVATE functions. +-- Following a Python-like convention, I have prefixed all these 'PRIVATE' +-- functions with an underscore. +----------------------------------------------------------------------------- + +--- Scans an array from JSON into a Lua object +-- startPos begins at the start of the array. +-- Returns the array and the next starting position +-- @param s The string being scanned. +-- @param startPos The starting position for the scan. +-- @return table, int The scanned array as a table, and the position of the next character to scan. +function decode_scanArray(s,startPos) + local array = {} -- The return value + local stringLen = string.len(s) + assert(string.sub(s,startPos,startPos)=='[','decode_scanArray called but array does not start at position ' .. startPos .. ' in string:\n'..s ) + startPos = startPos + 1 + -- Infinite loop for array elements + local index = 1 + repeat + startPos = decode_scanWhitespace(s,startPos) + assert(startPos<=stringLen,'JSON String ended unexpectedly scanning array.') + local curChar = string.sub(s,startPos,startPos) + if (curChar==']') then + return array, startPos+1 + end + if (curChar==',') then + startPos = decode_scanWhitespace(s,startPos+1) + end + assert(startPos<=stringLen, 'JSON String ended unexpectedly scanning array.') + object, startPos = json.decode(s,startPos) + array[index] = object + index = index + 1 + until false +end + +--- Scans a comment and discards the comment. +-- Returns the position of the next character following the comment. +-- @param string s The JSON string to scan. +-- @param int startPos The starting position of the comment +function decode_scanComment(s, startPos) + assert( string.sub(s,startPos,startPos+1)=='/*', "decode_scanComment called but comment does not start at position " .. startPos) + local endPos = string.find(s,'*/',startPos+2) + assert(endPos~=nil, "Unterminated comment in string at " .. startPos) + return endPos+2 +end + +--- Scans for given constants: true, false or null +-- Returns the appropriate Lua type, and the position of the next character to read. +-- @param s The string being scanned. +-- @param startPos The position in the string at which to start scanning. +-- @return object, int The object (true, false or nil) and the position at which the next character should be +-- scanned. +function decode_scanConstant(s, startPos) + local consts = { ["true"] = true, ["false"] = false, ["null"] = nil } + local constNames = {"true","false","null"} + + for i,k in pairs(constNames) do + if string.sub(s,startPos, startPos + string.len(k) -1 )==k then + return consts[k], startPos + string.len(k) + end + end + assert(nil, 'Failed to scan constant from string ' .. s .. ' at starting position ' .. startPos) +end + +--- Scans a number from the JSON encoded string. +-- (in fact, also is able to scan numeric +- eqns, which is not +-- in the JSON spec.) +-- Returns the number, and the position of the next character +-- after the number. +-- @param s The string being scanned. +-- @param startPos The position at which to start scanning. +-- @return number, int The extracted number and the position of the next character to scan. +function decode_scanNumber(s,startPos) + local endPos = startPos+1 + local stringLen = string.len(s) + local acceptableChars = "+-0123456789.e" + while (string.find(acceptableChars, string.sub(s,endPos,endPos), 1, true) + and endPos<=stringLen + ) do + endPos = endPos + 1 + end + local stringValue = 'return ' .. string.sub(s,startPos, endPos-1) + local stringEval = load(stringValue) + assert(stringEval, 'Failed to scan number [ ' .. stringValue .. '] in JSON string at position ' .. startPos .. ' : ' .. endPos) + return stringEval(), endPos +end + +--- Scans a JSON object into a Lua object. +-- startPos begins at the start of the object. +-- Returns the object and the next starting position. +-- @param s The string being scanned. +-- @param startPos The starting position of the scan. +-- @return table, int The scanned object as a table and the position of the next character to scan. +function decode_scanObject(s,startPos) + local object = {} + local stringLen = string.len(s) + local key, value + assert(string.sub(s,startPos,startPos)=='{','decode_scanObject called but object does not start at position ' .. startPos .. ' in string:\n' .. s) + startPos = startPos + 1 + repeat + startPos = decode_scanWhitespace(s,startPos) + assert(startPos<=stringLen, 'JSON string ended unexpectedly while scanning object.') + local curChar = string.sub(s,startPos,startPos) + if (curChar=='}') then + return object,startPos+1 + end + if (curChar==',') then + startPos = decode_scanWhitespace(s,startPos+1) + end + assert(startPos<=stringLen, 'JSON string ended unexpectedly scanning object.') + -- Scan the key + key, startPos = json.decode(s,startPos) + assert(startPos<=stringLen, 'JSON string ended unexpectedly searching for value of key ' .. key) + startPos = decode_scanWhitespace(s,startPos) + assert(startPos<=stringLen, 'JSON string ended unexpectedly searching for value of key ' .. key) + assert(string.sub(s,startPos,startPos)==':','JSON object key-value assignment mal-formed at ' .. startPos) + startPos = decode_scanWhitespace(s,startPos+1) + assert(startPos<=stringLen, 'JSON string ended unexpectedly searching for value of key ' .. key) + value, startPos = json.decode(s,startPos) + object[key]=value + until false -- infinite loop while key-value pairs are found +end + +-- START SoniEx2 +-- Initialize some things used by decode_scanString +-- You know, for efficiency +local escapeSequences = { + ["\\t"] = "\t", + ["\\f"] = "\f", + ["\\r"] = "\r", + ["\\n"] = "\n", + ["\\b"] = "\b" +} +setmetatable(escapeSequences, {__index = function(t,k) + -- skip "\" aka strip escape + return string.sub(k,2) +end}) +-- END SoniEx2 + +--- Scans a JSON string from the opening inverted comma or single quote to the +-- end of the string. +-- Returns the string extracted as a Lua string, +-- and the position of the next non-string character +-- (after the closing inverted comma or single quote). +-- @param s The string being scanned. +-- @param startPos The starting position of the scan. +-- @return string, int The extracted string as a Lua string, and the next character to parse. +function decode_scanString(s,startPos) + assert(startPos, 'decode_scanString(..) called without start position') + local startChar = string.sub(s,startPos,startPos) + -- START SoniEx2 + -- PS: I don't think single quotes are valid JSON + assert(startChar == [["]] or startChar == [[']],'decode_scanString called for a non-string') + --assert(startPos, "String decoding failed: missing closing " .. startChar .. " for string at position " .. oldStart) + local t = {} + local i,j = startPos,startPos + while string.find(s, startChar, j+1) ~= j+1 do + local oldj = j + i,j = string.find(s, "\\.", j+1) + local x,y = string.find(s, startChar, oldj+1) + if not i or x < i then + i,j = x,y-1 + end + table.insert(t, string.sub(s, oldj+1, i-1)) + if string.sub(s, i, j) == "\\u" then + local a = string.sub(s,j+1,j+4) + j = j + 4 + local n = tonumber(a, 16) + assert(n, "String decoding failed: bad Unicode escape " .. a .. " at position " .. i .. " : " .. j) + -- math.floor(x/2^y) == lazy right shift + -- a % 2^b == bitwise_and(a, (2^b)-1) + -- 64 = 2^6 + -- 4096 = 2^12 (or 2^6 * 2^6) + local x + if n < 0x80 then + x = string.char(n % 0x80) + elseif n < 0x800 then + -- [110x xxxx] [10xx xxxx] + x = string.char(0xC0 + (math.floor(n/64) % 0x20), 0x80 + (n % 0x40)) + else + -- [1110 xxxx] [10xx xxxx] [10xx xxxx] + x = string.char(0xE0 + (math.floor(n/4096) % 0x10), 0x80 + (math.floor(n/64) % 0x40), 0x80 + (n % 0x40)) + end + table.insert(t, x) + else + table.insert(t, escapeSequences[string.sub(s, i, j)]) + end + end + table.insert(t,string.sub(j, j+1)) + assert(string.find(s, startChar, j+1), "String decoding failed: missing closing " .. startChar .. " at position " .. j .. "(for string at position " .. startPos .. ")") + return table.concat(t,""), j+2 + -- END SoniEx2 +end + +--- Scans a JSON string skipping all whitespace from the current start position. +-- Returns the position of the first non-whitespace character, or nil if the whole end of string is reached. +-- @param s The string being scanned +-- @param startPos The starting position where we should begin removing whitespace. +-- @return int The first position where non-whitespace was encountered, or string.len(s)+1 if the end of string +-- was reached. +function decode_scanWhitespace(s,startPos) + local whitespace=" \n\r\t" + local stringLen = string.len(s) + while ( string.find(whitespace, string.sub(s,startPos,startPos), 1, true) and startPos <= stringLen) do + startPos = startPos + 1 + end + return startPos +end + +--- Encodes a string to be JSON-compatible. +-- This just involves back-quoting inverted commas, back-quotes and newlines, I think ;-) +-- @param s The string to return as a JSON encoded (i.e. backquoted string) +-- @return The string appropriately escaped. + +local escapeList = { + ['"'] = '\\"', + ['\\'] = '\\\\', + ['/'] = '\\/', + ['\b'] = '\\b', + ['\f'] = '\\f', + ['\n'] = '\\n', + ['\r'] = '\\r', + ['\t'] = '\\t' +} + +function json_private.encodeString(s) + local s = tostring(s) + return s:gsub(".", function(c) return escapeList[c] end) -- SoniEx2: 5.0 compat +end + +-- Determines whether the given Lua type is an array or a table / dictionary. +-- We consider any table an array if it has indexes 1..n for its n items, and no +-- other data in the table. +-- I think this method is currently a little 'flaky', but can't think of a good way around it yet... +-- @param t The table to evaluate as an array +-- @return boolean, number True if the table can be represented as an array, false otherwise. If true, +-- the second returned value is the maximum +-- number of indexed elements in the array. +function isArray(t) + -- Next we count all the elements, ensuring that any non-indexed elements are not-encodable + -- (with the possible exception of 'n') + if (t == json.EMPTY_ARRAY) then return true, 0 end + if (t == json.EMPTY_OBJECT) then return false end + + local maxIndex = 0 + for k,v in pairs(t) do + if (type(k)=='number' and math.floor(k)==k and 1<=k) then -- k,v is an indexed pair + if (not isEncodable(v)) then return false end -- All array elements must be encodable + maxIndex = math.max(maxIndex,k) + else + if (k=='n') then + if v ~= (t.n or #t) then return false end -- False if n does not hold the number of elements + else -- Else of (k=='n') + if isEncodable(v) then return false end + end -- End of (k~='n') + end -- End of k,v not an indexed pair + end -- End of loop across all pairs + return true, maxIndex +end + +--- Determines whether the given Lua object / table / variable can be JSON encoded. The only +-- types that are JSON encodable are: string, boolean, number, nil, table and json.null. +-- In this implementation, all other types are ignored. +-- @param o The object to examine. +-- @return boolean True if the object should be JSON encoded, false if it should be ignored. +function isEncodable(o) + local t = type(o) + return (t=='string' or t=='boolean' or t=='number' or t=='nil' or t=='table') or + (t=='function' and o==json.null) +end + +----------------------------------------------------------------------------- +-- Display version information for Wireshark +----------------------------------------------------------------------------- +if set_plugin_info~=nil then + local plugin_info = { + version = "1.0.1", + author = "Craig Mason-Jones", + repository = "https://github.com/craigmj/json4lua" + } + set_plugin_info(plugin_info) +end + +return json + diff --git a/libs/json4lua/json/rpc.lua b/libs/json4lua/json/rpc.lua new file mode 100755 index 0000000..952c5b5 --- /dev/null +++ b/libs/json4lua/json/rpc.lua @@ -0,0 +1,107 @@ +----------------------------------------------------------------------------- +-- JSONRPC4Lua: JSON RPC client calls over http for the Lua language. +-- json.rpc Module. +-- Author: Craig Mason-Jones +-- Homepage: http://github.com/craigmj/json4lua/ +-- Version: 1.0.0 +-- This module is released under the MIT License (MIT). +-- Please see LICENCE.txt for details. +-- +-- USAGE: +-- This module exposes two functions: +-- proxy( 'url') +-- Returns a proxy object for calling the JSON RPC Service at the given url. +-- call ( 'url', 'method', ...) +-- Calls the JSON RPC server at the given url, invokes the appropriate method, and +-- passes the remaining parameters. Returns the result and the error. If the result is nil, an error +-- should be there (or the system returned a null). If an error is there, the result should be nil. +-- +-- REQUIREMENTS: +-- Lua socket 2.0 (http://www.cs.princeton.edu/~diego/professional/luasocket/) +-- json (The JSON4Lua package with which it is bundled) +-- compat-5.1 if using Lua 5.0. +----------------------------------------------------------------------------- + +local json = require('json') +json.rpc = {} -- Module public namespace + +----------------------------------------------------------------------------- +-- Imports and dependencies +----------------------------------------------------------------------------- +local json = require('json') +local http = require("socket.http") + +----------------------------------------------------------------------------- +-- PUBLIC functions +----------------------------------------------------------------------------- + +--- Creates an RPC Proxy object for the given Url of a JSON-RPC server. +-- @param url The URL for the JSON RPC Server. +-- @return Object on which JSON-RPC remote methods can be called. +-- EXAMPLE Usage: +-- local jsolait = json.rpc.proxy('http://jsolait.net/testj.py') +-- print(jsolait.echo('This is a test of the echo method!')) +-- print(jsolait.args2String('first','second','third')) +-- table.foreachi( jsolait.args2Array(5,4,3,2,1), print) +function json.rpc.proxy(url) + local serverProxy = {} + local proxyMeta = { + __index = function(self, key) + return function(...) + return json.rpc.call(url, key, ...) + end + end + } + setmetatable(serverProxy, proxyMeta) + return serverProxy +end + +--- Calls a JSON RPC method on a remote server. +-- Returns a boolean true if the call succeeded, false otherwise. +-- On success, the second returned parameter is the decoded +-- JSON object from the server. +-- On http failure, returns nil and an error message. +-- On success, returns the result and nil. +-- @param url The url of the JSON RPC server. +-- @param method The method being called. +-- @param ... Parameters to pass to the method. +-- @return result, error The JSON RPC result and error. One or the other should be nil. If both +-- are nil, this means that the result of the RPC call was nil. +-- EXAMPLE Usage: +-- print(json.rpc.call('http://jsolait.net/testj.py','echo','This string will be returned')) +function json.rpc.call(url, method, ...) + local JSONRequestArray = { + id=tostring(math.random()), + ["method"]=method, + ["jsonrpc"]="2.0", + params = ... + } + local httpResponse, result , code + local jsonRequest = json.encode(JSONRequestArray) + -- We use the sophisticated http.request form (with ltn12 sources and sinks) so that + -- we can set the content-type to text/plain. While this shouldn't strictly-speaking be true, + -- it seems a good idea (Xavante won't work w/out a content-type header, although a patch + -- is needed to Xavante to make it work with text/plain) + local ltn12 = require('ltn12') + local resultChunks = {} + httpResponse, code = http.request( + { ['url'] = url, + sink = ltn12.sink.table(resultChunks), + method = 'POST', + headers = { ['content-type']='application/json-rpc', ['content-length']=string.len(jsonRequest) }, + source = ltn12.source.string(jsonRequest) + } + ) + httpResponse = table.concat(resultChunks) + -- Check the http response code + if (code~=200) then + return nil, "HTTP ERROR: " .. code + end + -- And decode the httpResponse and check the JSON RPC result code + result = json.decode( httpResponse ) + if result.result then + return result.result, nil + else + return nil, result.error + end +end diff --git a/libs/json4lua/json/rpcserver.lua b/libs/json4lua/json/rpcserver.lua new file mode 100755 index 0000000..e01f1f8 --- /dev/null +++ b/libs/json4lua/json/rpcserver.lua @@ -0,0 +1,78 @@ +----------------------------------------------------------------------------- +-- JSONRPC4Lua: JSON RPC server for exposing Lua objects as JSON RPC callable +-- objects via http. +-- json.rpcserver Module. +-- Author: Craig Mason-Jones +-- Homepage: http://github.com/craigmj/json4lua/ +-- Version: 1.0.0 +-- This module is released under the MIT License (MIT). +-- Please see LICENCE.txt for details. +-- +-- USAGE: +-- This module exposes one function: +-- server(luaClass, packReturn) +-- Manages incoming JSON RPC request forwarding the method call to the given +-- object. If packReturn is true, multiple return values are packed into an +-- array on return. +-- +-- IMPORTANT NOTES: +-- 1. This version ought really not be 0.9.10, since this particular part of the +-- JSONRPC4Lua package is very first-draft. However, the JSON4Lua package with which +-- it comes is quite solid, so there you have it :-) +-- 2. This has only been tested with Xavante webserver, with which it works +-- if you patch CGILua to accept 'text/plain' content type. See doc\cgilua_patch.html +-- for details. +---------------------------------------------------------------------------- + +module ('json.rpcserver') + +--- +-- Implements a JSON RPC Server wrapping for luaClass, exposing each of luaClass's +-- methods as JSON RPC callable methods. +-- @param luaClass The JSON RPC class to expose. +-- @param packReturn If true, the server will automatically wrap any +-- multiple-value returns into an array. Single returns remain single returns. If +-- false, when a function returns multiple values, only the first of these values will +-- be returned. +-- +function serve(luaClass, packReturn) + cgilua.contentheader('text','plain') + require('cgilua') + require ('json') + local postData = "" + + if not cgilua.servervariable('CONTENT_LENGTH') then + cgilua.put("Please access JSON Request using HTTP POST Request") + return 0 + else + postData = cgi[1] -- SAPI.Request.getpostdata() --[[{ "id":1, "method":"echo","params":["Hi there"]}]] -- + end + -- @TODO Catch an error condition on decoding the data + local jsonRequest = json.decode(postData) + local jsonResponse = {} + jsonResponse.id = jsonRequest.id + local method = luaClass[ jsonRequest.method ] + + if not method then + jsonResponse.error = 'Method ' .. jsonRequest.method .. ' does not exist at this server.' + else + local callResult = { pcall( method, unpack( jsonRequest.params ) ) } + if callResult[1] then -- Function call successfull + table.remove(callResult,1) + if packReturn and table.getn(callResult)>1 then + jsonResponse.result = callResult + else + jsonResponse.result = unpack(callResult) -- NB: Does not support multiple argument returns + end + else + jsonResponse.error = callResult[2] + end + end + + -- Output the result + -- TODO: How to be sure that the result and error tags are there even when they are nil in Lua? + -- Can force them by hand... ? + cgilua.contentheader('text','plain') + cgilua.put( json.encode( jsonResponse ) ) +end + diff --git a/libs/json4lua/json4lua-1.0.0-1.rockspec b/libs/json4lua/json4lua-1.0.0-1.rockspec new file mode 100644 index 0000000..b3b737a --- /dev/null +++ b/libs/json4lua/json4lua-1.0.0-1.rockspec @@ -0,0 +1,32 @@ +package="JSON4Lua" +version="1.0.0" +source = { + url = "git://github.com/craigmj/json4lua.git", + tag = "1.0.0" +} +description = { + summary = "JSON4Lua and JSONRPC4Lua implement JSON (JavaScript Object Notation) encoding and decoding and a JSON-RPC-over-http client for Lua.", + detailed = [[ + JSON4Lua and JSONRPC4Lua implement JSON (JavaScript Object Notation) + encoding and decoding and a JSON-RPC-over-http client for Lua. + JSON is JavaScript Object Notation, a simple encoding of + Javascript-like objects that is ideal for lightweight transmission + of relatively weakly-typed data. A sub-package of JSON4Lua is + JSONRPC4Lua, which provides a simple JSON-RPC-over-http client and server + (in a CGILua environment) for Lua. + ]], + homepage = "http://github.com/craigmj/json4lua/", + license = "GPL" +} +dependencies = { + "lua >= 5.2", + "luasocket", +} + +build = { + type = "builtin", + modules = { + ["json"] = "json/json.lua", + ["json.rpc"] = "json/rpc.lua" + } +} diff --git a/libs/libs/TSerial.lua b/libs/libs/TSerial.lua deleted file mode 100644 index 7c03049..0000000 --- a/libs/libs/TSerial.lua +++ /dev/null @@ -1,63 +0,0 @@ ---- Tserial v1.51d, a simple table serializer which turns tables into Lua script --- @author Taehl (SelfMadeSpirit@gmail.com) -Tserial = {} -TSerial = Tserial -- for backwards-compatibility - ---- Serializes a table into a string, in form of Lua script. --- @param t table to be serialized (may not contain any circular reference) --- @param drop if true, unserializable types will be silently dropped instead of raising errors --- if drop is a function, it will be called to serialize unsupported types --- if drop is a table, it will be used as a serialization table (where {[value] = serial}) --- @param indent if true, output "human readable" mode with newlines and indentation (for debug) --- @return string recreating given table -function Tserial.pack(t, drop, indent) - assert(type(t) == "table", "Can only Tserial.pack tables.") - local s, empty, indent = "{"..(indent and "\n" or ""), true, indent and math.max(type(indent)=="number" and indent or 0,0) - local function proc(k,v, omitKey) -- encode a key/value pair - empty = nil -- helps ensure empty tables return as "{}" - local tk, tv, skip = type(k), type(v) - if type(drop)=="table" and drop[k] then k = "["..drop[k].."]" - elseif tk == "boolean" then k = k and "[true]" or "[false]" - elseif tk == "string" then - local f = string.format("%q",k) - if f ~= '"'..k..'"' or string.find(k, " ") then k = '['..f..']' end - elseif tk == "number" then k = "["..k.."]" - elseif tk == "table" then k = "["..Tserial.pack(k, drop, indent and indent+1).."]" - elseif type(drop) == "function" then k = "["..string.format("%q",drop(k)).."]" - elseif drop then skip = true - else error("Attempted to Tserial.pack a table with an invalid key: "..tostring(k)) - end - if type(drop)=="table" and drop[v] then v = drop[v] - elseif tv == "boolean" then v = v and "true" or "false" - elseif tv == "string" then v = string.format("%q", v) - elseif tv == "number" then -- no change needed - elseif tv == "table" then v = Tserial.pack(v, drop, indent and indent+1) - elseif type(drop) == "function" then v = string.format("%q",drop(v)) - elseif drop then skip = true - else error("Attempted to Tserial.pack a table with an invalid value: "..tostring(v)) - end - if not skip then return string.rep("\t",indent or 0)..(omitKey and "" or k.."=")..v..","..(indent and "\n" or "") end - return "" - end - local l, did=-1,{} repeat l=l+1 until t[l+1]==nil -- #t "can" lie! - for i=1,l do s = s..proc(i, t[i], true) did[i]=true end -- use ordered values when possible for better string - for k, v in pairs(t) do if not did[k] then s = s..proc(k, v) end end - if not empty then s = string.sub(s,1,string.len(s)-1) end - if indent then s = string.sub(s,1,string.len(s)-1).."\n" end - return s..string.rep("\t",(indent or 1)-1).."}" -end - ---- Loads a table into memory from a string (like those output by Tserial.pack) --- @param s a string of Lua defining a table, such as "{2,4,8,ex='ample'}" --- @param safe if true, all extraneous parts of the string will be removed, leaving only a table (prevents running anomalous code when unpacking untrusted strings). Will also cause malformed tables to quietly return nil and an error message, instead of throwing an error (so your program can't be crashed with a bad string) --- @return a table recreated from the given string. -function Tserial.unpack(s, safe) - if safe then s = string.match(s, "(%b{})") end - assert(type(s) == "string", "Can only Tserial.unpack strings.") - local f, result = loadstring("Tserial.table="..s) - if not safe then assert(f,result) elseif not f then return nil, result end - result = f() - local t = Tserial.table - Tserial.table = nil - return t, result -end \ No newline at end of file diff --git a/libs/libs/hump b/libs/libs/hump deleted file mode 160000 index 08937cc..0000000 --- a/libs/libs/hump +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 08937cc0ecf72d1a964a8de6cd552c5e136bf0d4 diff --git a/libs/libs/json4lua b/libs/libs/json4lua deleted file mode 160000 index a0da807..0000000 --- a/libs/libs/json4lua +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a0da807dca77baf07d287631f5ad41a9097fc25c diff --git a/libs/libs/middleclass.lua b/libs/libs/middleclass.lua deleted file mode 100644 index 57d3644..0000000 --- a/libs/libs/middleclass.lua +++ /dev/null @@ -1,182 +0,0 @@ -local middleclass = { - _VERSION = 'middleclass v3.0.1', - _DESCRIPTION = 'Object Orientation for Lua', - _URL = 'https://github.com/kikito/middleclass', - _LICENSE = [[ - MIT LICENSE - - Copyright (c) 2011 Enrique García Cota - - 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 function _setClassDictionariesMetatables(aClass) - local dict = aClass.__instanceDict - dict.__index = dict - - local super = aClass.super - if super then - local superStatic = super.static - setmetatable(dict, super.__instanceDict) - setmetatable(aClass.static, { __index = function(_,k) return dict[k] or superStatic[k] end }) - else - setmetatable(aClass.static, { __index = function(_,k) return dict[k] end }) - end -end - -local function _setClassMetatable(aClass) - setmetatable(aClass, { - __tostring = function() return "class " .. aClass.name end, - __index = aClass.static, - __newindex = aClass.__instanceDict, - __call = function(self, ...) return self:new(...) end - }) -end - -local function _createClass(name, super) - local aClass = { name = name, super = super, static = {}, __mixins = {}, __instanceDict={} } - aClass.subclasses = setmetatable({}, {__mode = "k"}) - - _setClassDictionariesMetatables(aClass) - _setClassMetatable(aClass) - - return aClass -end - -local function _createLookupMetamethod(aClass, name) - return function(...) - local method = aClass.super[name] - assert( type(method)=='function', tostring(aClass) .. " doesn't implement metamethod '" .. name .. "'" ) - return method(...) - end -end - -local function _setClassMetamethods(aClass) - for _,m in ipairs(aClass.__metamethods) do - aClass[m]= _createLookupMetamethod(aClass, m) - end -end - -local function _setDefaultInitializeMethod(aClass, super) - aClass.initialize = function(instance, ...) - return super.initialize(instance, ...) - end -end - -local function _includeMixin(aClass, mixin) - assert(type(mixin)=='table', "mixin must be a table") - for name,method in pairs(mixin) do - if name ~= "included" and name ~= "static" then aClass[name] = method end - end - if mixin.static then - for name,method in pairs(mixin.static) do - aClass.static[name] = method - end - end - if type(mixin.included)=="function" then mixin:included(aClass) end - aClass.__mixins[mixin] = true -end - -local Object = _createClass("Object", nil) - -Object.static.__metamethods = { '__add', '__call', '__concat', '__div', '__ipairs', '__le', - '__len', '__lt', '__mod', '__mul', '__pairs', '__pow', '__sub', - '__tostring', '__unm'} - -function Object.static:allocate() - assert(type(self) == 'table', "Make sure that you are using 'Class:allocate' instead of 'Class.allocate'") - return setmetatable({ class = self }, self.__instanceDict) -end - -function Object.static:new(...) - local instance = self:allocate() - instance:initialize(...) - return instance -end - -function Object.static:subclass(name) - assert(type(self) == 'table', "Make sure that you are using 'Class:subclass' instead of 'Class.subclass'") - assert(type(name) == "string", "You must provide a name(string) for your class") - - local subclass = _createClass(name, self) - _setClassMetamethods(subclass) - _setDefaultInitializeMethod(subclass, self) - self.subclasses[subclass] = true - self:subclassed(subclass) - - return subclass -end - -function Object.static:subclassed(other) end - -function Object.static:isSubclassOf(other) - return type(other) == 'table' and - type(self) == 'table' and - type(self.super) == 'table' and - ( self.super == other or - type(self.super.isSubclassOf) == 'function' and - self.super:isSubclassOf(other) - ) -end - -function Object.static:include( ... ) - assert(type(self) == 'table', "Make sure you that you are using 'Class:include' instead of 'Class.include'") - for _,mixin in ipairs({...}) do _includeMixin(self, mixin) end - return self -end - -function Object.static:includes(mixin) - return type(mixin) == 'table' and - type(self) == 'table' and - type(self.__mixins) == 'table' and - ( self.__mixins[mixin] or - type(self.super) == 'table' and - type(self.super.includes) == 'function' and - self.super:includes(mixin) - ) -end - -function Object:initialize() end - -function Object:__tostring() return "instance of " .. tostring(self.class) end - -function Object:isInstanceOf(aClass) - return type(self) == 'table' and - type(self.class) == 'table' and - type(aClass) == 'table' and - ( aClass == self.class or - type(aClass.isSubclassOf) == 'function' and - self.class:isSubclassOf(aClass) - ) -end - - - -function middleclass.class(name, super, ...) - super = super or Object - return super:subclass(name, ...) -end - -middleclass.Object = Object - -setmetatable(middleclass, { __call = function(_, ...) return middleclass.class(...) end }) - -return middleclass diff --git a/libs/libs/sfxrlua b/libs/libs/sfxrlua deleted file mode 160000 index 949429d..0000000 --- a/libs/libs/sfxrlua +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 949429d4618aee8b019899af101bd5f793d07a64 diff --git a/libs/narrator b/libs/narrator deleted file mode 160000 index 45a8b03..0000000 --- a/libs/narrator +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 45a8b03553f2385716c24ec762757e418b2b73c1 diff --git a/libs/narrator/.gitignore b/libs/narrator/.gitignore new file mode 100644 index 0000000..e0fcd36 --- /dev/null +++ b/libs/narrator/.gitignore @@ -0,0 +1,7 @@ +# System +.DS_Store +Thumbs.db + +# Defold +/.internal +/build \ No newline at end of file diff --git a/libs/narrator/.vscode/launch.json b/libs/narrator/.vscode/launch.json new file mode 100644 index 0000000..1f1a002 --- /dev/null +++ b/libs/narrator/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "configurations": [ + { + "name": "Debug", + "type": "lua-local", + "request": "launch", + "program": { + "lua": "lua", + "file": "${workspaceFolder}/debug.lua" + } + }, + { + "name": "Busted", + "type": "lua-local", + "request": "launch", + "program": { + "command": "busted" + }, + "args": [ + "${workspaceFolder}/test/run.lua", + ], + } + ] +} \ No newline at end of file diff --git a/libs/narrator/.vscode/settings.json b/libs/narrator/.vscode/settings.json new file mode 100644 index 0000000..a5dd08d --- /dev/null +++ b/libs/narrator/.vscode/settings.json @@ -0,0 +1,81 @@ +{ + "Lua.diagnostics.globals": [ + "unpack", + "loadstring", + "describe", + "it", + "msg", + "sound", + "hash", + "vmath", + "gui", + "socket", + "sys", + "render", + "go", + "factory", + "resource", + "pprint", + "timer", + "particlefx", + "spine", + "sprite", + "json", + "window", + "physics" + ], + "Lua.diagnostics.disable": [ + "trailing-space", + "redefined-local", + "deprecated", + "lowercase-global" + ], + "Lua.completion.callSnippet": "Replace", + "Lua.completion.keywordSnippet": "Replace", + "Lua.completion.showWord": "Fallback", + "Lua.completion.autoRequire": false, + "[lua]": { + "editor.defaultFormatter": "sumneko.lua" + }, + "glsllint.additionalStageAssociations": { + ".fp": "frag", + ".vp": "vert" + }, + "files.associations": { + "*.project": "ini", + "*.script": "lua", + "*.gui_script": "lua", + "*.render_script": "lua", + "*.editor_script": "lua", + "*.fp": "glsl", + "*.vp": "glsl", + "*.go": "textproto", + "*.animationset": "textproto", + "*.atlas": "textproto", + "*.buffer": "json", + "*.camera": "textproto", + "*.collection": "textproto", + "*.collectionfactory": "textproto", + "*.collectionproxy": "textproto", + "*.collisionobject": "textproto", + "*.display_profiles": "textproto", + "*.factory": "textproto", + "*.gamepads": "textproto", + "*.gui": "textproto", + "*.input_binding": "textproto", + "*.label": "textproto", + "*.material": "textproto", + "*.mesh": "textproto", + "*.model": "textproto", + "*.particlefx": "textproto", + "*.render": "textproto", + "*.sound": "textproto", + "*.spinemodel": "textproto", + "*.spinescene": "textproto", + "*.sprite": "textproto", + "*.texture_profiles": "textproto", + "*.tilemap": "textproto", + "*.tilesource": "textproto", + "*.manifest": "textproto" + } +} diff --git a/libs/narrator/.vscode/tasks.json b/libs/narrator/.vscode/tasks.json new file mode 100644 index 0000000..f849522 --- /dev/null +++ b/libs/narrator/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Busted", + "command": "busted", + "args": [ + "${workspaceFolder}/test/run.lua" + ], + "type": "shell", + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} \ No newline at end of file diff --git a/libs/narrator/LICENSE b/libs/narrator/LICENSE new file mode 100644 index 0000000..571b43d --- /dev/null +++ b/libs/narrator/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Roman Silin + +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. diff --git a/libs/narrator/README.md b/libs/narrator/README.md new file mode 100644 index 0000000..59d2252 --- /dev/null +++ b/libs/narrator/README.md @@ -0,0 +1,469 @@ +![logo](https://user-images.githubusercontent.com/4752473/85455900-141f8f80-b5a7-11ea-8cd7-b441d662b361.png) + +# Narrator + +[![Release](https://img.shields.io/github/v/release/astrochili/narrator.svg?include_prereleases=&sort=semver&color=blue)](https://github.com/astrochili/narrator/releases) +[![License](https://img.shields.io/badge/License-MIT-blue)](https://github.com/astrochili/narrator/blob/master/LICENSE) +[![Website](https://img.shields.io/badge/website-gray.svg?&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSIgdmlld0JveD0iMCAwIDE4IDE2Ij48Y2lyY2xlIGN4PSIzLjY2IiBjeT0iMTQuNzUiIHI9IjEuMjUiIGZpbGw9InVybCgjYSkiLz48Y2lyY2xlIGN4PSI4LjY2IiBjeT0iMTQuNzUiIHI9IjEuMjUiIGZpbGw9InVybCgjYikiLz48Y2lyY2xlIGN4PSIxMy42NSIgY3k9IjE0Ljc1IiByPSIxLjI1IiBmaWxsPSJ1cmwoI2MpIi8+PHBhdGggZmlsbD0idXJsKCNkKSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNNy42MyAxLjQ4Yy41LS43IDEuNTUtLjcgMi4wNSAwbDYuMjIgOC44MWMuNTguODMtLjAxIDEuOTctMS4wMyAxLjk3SDIuNDRhMS4yNSAxLjI1IDAgMCAxLTEuMDItMS45N2w2LjIxLTguODFaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyLjQxIiB4Mj0iMi40MSIgeTE9IjEzLjUiIHkyPSIxNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNGRDhENDIiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGOTU0MUYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjcuNDEiIHgyPSI3LjQxIiB5MT0iMTMuNSIgeTI9IjE2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iI0ZEOEQ0MiIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI0Y5NTQxRiIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJjIiB4MT0iMTIuNCIgeDI9IjEyLjQiIHkxPSIxMy41IiB5Mj0iMTYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjRkQ4RDQyIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjRjk1NDFGIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImQiIHgxPSIuMDMiIHgyPSIuMDMiIHkxPSIuMDMiIHkyPSIxMi4yNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNGRkU2NUUiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRkM4MzAiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4=)](https://astronachos.com/) +[![Mastodon](https://img.shields.io/badge/mastodon-gray?&logo=mastodon)](https://mastodon.gamedev.place/@astronachos) +[![Twitter](https://img.shields.io/badge/twitter-gray?&logo=twitter)](https://twitter.com/astronachos) +[![Telegram](https://img.shields.io/badge/telegram-gray?&logo=telegram)](https://t.me/astronachos) +[![Buy me a coffee](https://img.shields.io/badge/buy_me_a_coffee-gray?&logo=buy%20me%20a%20coffee)](https://buymeacoffee.com/astrochili) + +## Overview + +The [Ink](https://www.inklestudios.com/ink/) language parser and runtime implementation in Lua. + +Ink is a powerful narrative scripting language. You can find more information about how to write Ink scripts [here](https://github.com/inkle/ink/blob/master/Documentation/WritingWithInk.md). There is also [Inky](https://github.com/inkle/inky) editor with useful features to test and debug Ink scripts. + +Narrator allows to convert raw Ink scripts to the book (a lua table) and play it as story. + +- 📖 A book is a passive model on the shelf like a game level. +- ✨ A story is a runtime state of the book reading like a game process. + +## Quick example + +```lua +local narrator = require('narrator.narrator') + +-- Parse a book from the Ink file. +local book = narrator.parse_file('stories.game') + +-- Init a story from the book +local story = narrator.init_story(book) + +-- Begin the story +story:begin() + +while story:can_continue() do + + -- Get current paragraphs to output + local paragraphs = story:continue() + + for _, paragraph in ipairs(paragraphs) do + local text = paragraph.text + + -- You can handle tags as you like, but we attach them to text here. + if paragraph.tags then + text = text .. ' #' .. table.concat(paragraph.tags, ' #') + end + + -- Output text to the player + print(text) + end + + -- If there is no choice it seems like the game is over + if not story:can_choose() then break end + + -- Get available choices and output them to the player + local choices = story:get_choices() + for i, choice in ipairs(choices) do + print(i .. ') ' .. choice.text) + end + + -- Read the choice from the player input + local answer = tonumber(io.read()) + + -- Send answer to the story to generate new paragraphs + story:choose(answer) +end +``` + +## Alternatives + +- [defold-ink](https://github.com/abadonna/defold-ink) — The Ink language runtime implementation in Lua based on parsing compiled JSON files. + +## Showcase + +- [Cat's Day](https://astronachos.com/catsday/) — A short card game about one furry. +- [Rare Pets](https://jetpackcollective.games/rarepets/) — A merge game for mobile about pets that become what they eat. +- [Sensual Hunting](https://store.steampowered.com/app/1967470/Sensual_Haunting/) (NSFW) — An adult only game where all the navigation and dialogs made with this library. +- [The Secret Laboratory](https://astrochili.itch.io/the-secret-laboratory) — A short card game about the labaratory director. + +## Features + +### Supported + +- [x] Comments: singleline, multiline, todo's +- [x] Tags: global tags, knot tags, stitch tags, paragraph tags +- [x] Paths and sections: inclusions, knots, stitches, labels +- [x] Choices: suppressing and mixing, labels, conditions, sticky and fallback choices, tags +- [x] Branching: diversions, glues, gathers, nesting +- [x] Tunnels +- [x] Alternatives: sequences, cycles, once-only, shuffles, empty steps, nesting +- [x] Multiline alternatives: all the same + shuffle options +- [x] Conditions: logical operations, string queries, if and else statements, nesting +- [x] Multiline conditions: all the same + elseif statements, switches, nesting +- [x] Variables: assignments, constants, global variables, temporary variables, visits, lists +- [x] Lists: logical operations, multivalued lists, multi-list lists, all the queries, work with numbers +- [x] Game queries: all the queries without `TURNS()` and `TURNS_SINCE()` +- [x] State: saving and loading +- [x] Integration: external functions, variables observing, jumping +- [x] Migration: the ability to implement the migration of player's saves after the book update +- [x] Internal functions + +### Unsupported + +- [ ] [Threads](https://github.com/astrochili/narrator/issues/22) +- [ ] [Divert target as variable type](https://github.com/astrochili/narrator/issues/23) +- [ ] [Assigning string evaluations to variables](https://github.com/astrochili/narrator/issues/24) +- [ ] [Multiple parallel flows](https://github.com/astrochili/narrator/issues/25) + +Also there is a list of [known limitations](https://github.com/astrochili/narrator/labels/known%20limitation) on the issues page. + +## Installation + +### Common case (Löve, pure Lua, etc.) + +Download the latest [release archive](https://github.com/astrochili/narrator/releases) and require the `narrator` module. + +```lua +local narrator = require('narrator.narrator') +``` + +Narrator requires [lpeg](http://www.inf.puc-rio.br/~roberto/lpeg/) as dependency to parse Ink content. You can install it with [luarocks](https://luarocks.org/). + +```shell +$ luarocks install lpeg +``` + +In fact, you don't need `lpeg` in the release, but you need it locally to parse Ink content and generate lua versions of books to play in your game. Use parsing in development only, prefer already parsed and stored books in production. + +### Defold + +Add links to the zip-archives of the latest versions of [narrator](https://github.com/astrochili/narrator/releases) and [defold-lpeg](https://github.com/astrochili/defold-lpeg/releases) to your Defold project as [dependencies](http://www.defold.com/manuals/libraries/). + +``` +https://github.com/astrochili/narrator/archive/master.zip +https://github.com/astrochili/defold-lpeg/archive/master.zip +``` + +Then you can require the `narrator` module. + +```lua +local narrator = require('narrator.narrator') +``` + +## Documentation + +### narrator.parse_file(path, params) + +Parses the Ink file at path with all the inclusions and returns a book instance. Path notations `'stories/game.ink'`, `'stories/game'` and `'stories.game'` are valid. + +You can save a parsed book to the lua file with the same path by passing `{ save = true }` as `params` table. By default, the `params` table is `{ save = false }`. + +```lua +-- Parse a Ink file at path 'stories/game.ink' +local book = narrator.parse_file('stories.game') + +-- Parse a Ink file at path 'stories/game.ink' +-- and save the book at path 'stories/game.lua' +local book = narrator.parse_file('stories.game', { save = true }) +``` +Reading and saving files required `io` so if you can't work with files by this way use `narrator.parse_content()`. + +### narrator.parse_content(content, inclusions) + +Parses the string with Ink content and returns a book instance. The `inclusions` param is optional and can be used to pass an array of strings with Ink content of inclusions. + +```lua +local content = 'Content of a root Ink file' +local inclusions = { + 'Content of an included Ink file', + 'Content of another included Ink file' +} + +-- Parse a string with Ink content +local book = narrator.parse_content(content) + +-- Parse a string with Ink content and inclusions +local book = narrator.parse_content(content, inclusions) +``` + +Content parsing is useful when you should manage files by your engine environment and don't want to use `io` module. For example, in Defold, you may want to load ink files as custom resources with [sys.load_resource()](https://defold.com/ref/sys/#sys.load_resource:filename). + +### narrator.init_story(book) + +Inits a story instance from the book. This is aclual to use in production. For example, just load a book with `require()` and pass it to this function. + +```lua +-- Require a parsed and saved before book +local book = require('stories.game') + +-- Init a story instance +local story = narrator.init_story(book) +``` + +### story:begin() + +Begins the story. Generates the first chunk of paragraphs and choices. + +### story:can_continue() + +Returns a boolean, does the story have paragraphs to output or not. + +```lua +while story:can_continue() do + -- Get paragraphs? +end +``` + +### story:continue(steps) + +Get the next paragraphs. You can specify the number of paragraphs that you want to pull by the `steps` param. +- Pass nothing if you want to get all the currently available paragraphs. `0` also works. +- Pass `1` if you want to get one next paragraph without wrapping to array. + +A paragraph is a table like `{ text = 'Hello.', tags = { 'tag1', 'tag2' } }`. Most of the paragraphs do not have tags so `tags` can be `nil`. + + +```lua +-- Get all the currently available paragraphs +local paragraphs = story:continue() + +-- Get one next paragraph +local paragraph = story:continue(1) +``` + +### story:can_choose() + +Returns a boolean, does the story have choices to output or not. Also returns `false` if there are available paragraphs to continue. + +```lua +if story:can_choose() do + -- Get choices? +end +``` + +### story:get_choices() + +Returns an array of available choices. Returns an empty array if there are available paragraphs to continue. + +A choice is a table like `{ text = 'Bye.', tags = { 'tag1', 'tag2' } }`. Most of the choices do not have tags so `tags` can be `nil`. + +Choice tags are not an official feature of Ink, but it's a Narrator feature. These tags also will appear in the answer paragraph as it works in Ink by default. But if you have a completely eaten choice like `'[Answer] #tag'` you will receive tags only in the choice. + +```lua + -- Get available choices and output them to the player + local choices = story:get_choices() + for i, choice in ipairs(choices) do + print(i .. ') ' .. choice.text) + end +``` + +### story:choose(index) + +Make a choice to continue the story. Pass the `index` of the choice that you was received with `get_choices()` before. Will do nothing if `can_continue()` returns `false`. + +```lua + -- Get the answer from the player in the terminal + answer = tonumber(io.read()) + + -- Send the answer to the story to generate new paragraphs + story:choose(answer) + + -- Get the new paragraphs + local new_paragraphs = story:continue() +``` + +### story:jump_to(path_string) + +Jumps to the path. The `path_string` param is a string like `'knot.stitch.label'`. + +```lua + -- Jump to the maze stitch in the adventure knot + story:jump_to('adventure.maze') + + -- Get the maze paragraphs + local maze_paragraphs = story:continue() +``` + +### story:get_visits(path_string) + +Returns the number of visits to the path. The `path_string` param is a string like `'knot.stitch.label'`. + +```lua +-- Get the number of visits to the maze's red room +local red_room_visits = story:get_visits('adventure.maze.red_room') + +-- Get the number of adventures visited. +local adventure_visits = story:get_visits('adventure') +``` + +### story:get_tags(path_string) + +Returns tags for the path. The `path_string` param is a string like `'knot.stitch'`. This function is useful when you want to get tags before continue the story and pull paragraphs. Read more about it [here](https://github.com/inkle/ink/blob/master/Documentation/RunningYourInk.md#knot-tags). + +```lua +-- Get tags for the path 'adventure.maze' +local mazeTags = story:get_tags('adventure.maze') +``` + +### story:save_state() + +Raturns a table with the story state that can be saved and restored later. Use it to save the game. + +```lua +-- Get the story's state +local state = story:save_state() + +-- Save the state to your local storage +manager.save(state) +``` + +### story:load_state(state) + +Restores a story's state from the saved before state. Use it to load the game. + +```lua +-- Load the state from your local storage +local state = manager.load() + +-- Restore the story's state +story:load_state(state) + +``` + +### story:observe(variable, observer) + +Assigns an observer function to the variable's changes. + +```lua +local function x_did_change(x) + print('The x did change! Now it\'s ' .. x) +end + +-- Start observing the variable 'x' +story:observe('x', x_did_change) +``` + +### story:bind(func_name, handler) + +Binds a function to external calling from the Ink. The function can returns the value or not. + +```lua +local function beep() + print('Beep! 😃') +end + +local function sum(x, y) + return x + y +end + +-- Bind the function without params and returned value +story:bind('beep', beep) + +-- Bind the function with params and returned value +story:bind('sum', sum) +``` + +### story.global_tags + +An array with book's global tags. Tags are strings of course. + +```lua +-- Get the global tags +local global_tags = story.global_tags + +-- A hacky way to get the same global tags +local global_tags = story:get_tags() +``` + +### story.constants + +A table with book's constants. Just read them, constants changing is not a good idea. + +```lua +-- Get the theme value from the Ink constants +local theme = story.constants['theme'] +``` + +### story.variables + +A table with story's variables. You can read or change them by this way. + +```lua +-- Get the mood variable value +local mood = story.variables['mood'] + +-- Set the mood variable value +story.variables['mood'] = 'sunny' +``` + +### story.migrate + +A function that you can specify for migration from old to new versions of your books. This is useful, for example, when you don't want to corrupt player's save after the game update. + +This is the place where you can rename or change variables, visits, update the current path, etc. The default implementation returns the same state without any migration. + +```lua +-- Default implementation +function(state, old_version, new_version) return state end +``` + +The `old_version` is the version of the saved state, the `new_version` is the version of the book. You can specify the verson of the book with the constant `'version'` in the Ink content, otherwise it's equal to `0`. + +```lua +-- A migration function example +local function migrate(state, old_version, new_version) + + -- Check the need for migration + if new_version == old_version then + return state + end + + -- Migration for the second version of the book + if new_version == 2 then + + -- Get the old value + local old_mood = state.variables['mood'] + + -- If it exists then migrate ... + if old_mood then + -- ... migrate the old number value to the new string value + state.variables['mood'] = old_mood < 50 and 'sadly' or 'sunny' + end + end + + return state +end + +-- Assign the migration function before loading a saved game +story.migrate = migrate + +-- Load the game +story:load_state(saved_state) +``` + +## Contribution + +### Development + +There are some useful extensions and configs for [VSCode](https://code.visualstudio.com/) that I use in development of Narrator. + +- [Local Lua Debugger](https://github.com/tomblind/local-lua-debugger-vscode) by [tomblind](https://github.com/tomblind/). +- [Lua Language Server](https://github.com/sumneko/lua-language-server) by [sunmeko](https://github.com/sumneko). +- A task named `Busted` runs tests with `tests/run.lua`. +- A lunch configuration named `Busted` runs the debugger with `tests/run.lua`. +- A lunch configuration named `Debug` runs the debugger with `debug.lua`. + +### Testing + +To run tests you need to install [busted](https://github.com/Olivine-Labs/busted). + +```shell +$ luarocks install busted +``` + +Don't forget also to install `lpeg` as described in [Common case](#common-case-löve-pure-lua-etc) installation section. + +After that you can run tests from the terminal: +```shell +$ busted test/run.lua +``` + +## Third Party Libraries + +- [LPeg](http://www.inf.puc-rio.br/~roberto/lpeg/) by [Roberto Ierusalimschy](http://www.inf.puc-rio.br/~roberto/) (MIT Licence). +- [classic](https://github.com/rxi/classic) by [rxi](https://github.com/rxi) (MIT Licence). +- [lume](https://github.com/rxi/lume) by [rxi](https://github.com/rxi) (MIT Licence). diff --git a/libs/narrator/bot.lua b/libs/narrator/bot.lua new file mode 100644 index 0000000..b03c63e --- /dev/null +++ b/libs/narrator/bot.lua @@ -0,0 +1,63 @@ +-- +-- Bot for story playing + +local bot = { } + +--- Play a story by bot +-- @param story Story: a story instance +-- @param instructor function: function that will be return the answer index +-- @param params.print boolean: print a game log to console or not, false by default +-- @return string: a log of the game +function bot.play(story, instructor, params) + local params = params or { print = false } + + local log = { } + local step = 1 + + local function output(text) + if params.print then print(text) end + table.insert(log, text) + end + + story:begin() + + while story:can_continue() or story:can_choose() do + local paragraphs = story:continue() + for _, paragraph in ipairs(paragraphs or { }) do + local text = paragraph.text or '' + if paragraph.tags then + local hashtag = #text > 0 and ' #' or '#' + text = text .. hashtag .. table.concat(paragraph.tags, ' #') + end + output(text) + end + + if not story:can_choose() then break end + + local choices = story:get_choices() + local answer = instructor(choices, step) + step = step + 1 + + -- Check for a signal to emergency exit + if answer == -1 then + return nil + end + + output('') + for i, choice in ipairs(choices) do + local prefix = (i == answer and '>' or i) .. ') ' + local text = prefix .. choice.text + if choice.tags then + text = text .. ' #' .. table.concat(choice.tags, ' #') + end + output(text) + end + output('') + + story:choose(answer) + end + + return table.concat(log, '\n') +end + +return bot \ No newline at end of file diff --git a/libs/narrator/debug.lua b/libs/narrator/debug.lua new file mode 100644 index 0000000..c831723 --- /dev/null +++ b/libs/narrator/debug.lua @@ -0,0 +1,30 @@ +-- +-- Script for debugging in VSCode with +-- Local Lua Debugger by Tom Blind (https://github.com/tomblind/local-lua-debugger-vscode) + +-- Dependencies +local narrator = require('narrator.narrator') +local bot = require('bot') + +-- Loading +-- local book = require('stories.debug') +-- local book = narrator.parseBook('Hello world!', { '=== one === \n text 1', '=== two === \n text 2' }) +local book = narrator.parse_file('stories.debug', { save = false }) + +local story = narrator.init_story(book) +local answers = { } + +-- Choice instructor for a bot +local function instructor(choices, step) + local answer = answers[step] + if answer == nil then + math.randomseed(os.time() * 10000000) + answer = math.random(1, #choices) + end + return answer +end + +-- Game +print('--- Game started ---\n') +bot.play(story, instructor, { print = true }) +print('\n--- Game over ---') \ No newline at end of file diff --git a/libs/narrator/example-defold/book.ink b/libs/narrator/example-defold/book.ink new file mode 100644 index 0000000..7f05169 --- /dev/null +++ b/libs/narrator/example-defold/book.ink @@ -0,0 +1,19 @@ +- I looked at Monsieur Fogg +* ... and I could contain myself no longer. + 'What is the purpose of our journey, Monsieur?' + 'A wager,' he replied. + * * 'A wager!'[] I returned. + He nodded. + * * * 'But surely that is foolishness!' + * * * 'A most serious matter then!' + - - - He nodded again. + * * * 'But can we win?' + 'That is what we will endeavour to find out,' he answered. + * * * 'A modest wager, I trust?' + 'Twenty thousand pounds,' he replied, quite flatly. + * * * I asked nothing further of him then[.], and after a final, polite cough, he offered nothing more to me. <> + * * 'Ah[.'],' I replied, uncertain what I thought. + - - After that, <> +* ... but I said nothing[] and <> +- we passed the day in silence. +- -> END \ No newline at end of file diff --git a/libs/narrator/example-defold/examlpe.collection b/libs/narrator/example-defold/examlpe.collection new file mode 100644 index 0000000..b59431a --- /dev/null +++ b/libs/narrator/example-defold/examlpe.collection @@ -0,0 +1,39 @@ +name: "main" +scale_along_z: 0 +embedded_instances { + id: "example" + data: "components {\n" + " id: \"example\"\n" + " component: \"/example-defold/example.gui\"\n" + " position {\n" + " x: 0.0\n" + " y: 0.0\n" + " z: 0.0\n" + " }\n" + " rotation {\n" + " x: 0.0\n" + " y: 0.0\n" + " z: 0.0\n" + " w: 1.0\n" + " }\n" + " property_decls {\n" + " }\n" + "}\n" + "" + position { + x: 0.0 + y: 0.0 + z: 0.0 + } + rotation { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + scale3 { + x: 1.0 + y: 1.0 + z: 1.0 + } +} diff --git a/libs/narrator/example-defold/example.font b/libs/narrator/example-defold/example.font new file mode 100644 index 0000000..b5b66d9 --- /dev/null +++ b/libs/narrator/example-defold/example.font @@ -0,0 +1,17 @@ +font: "/builtins/fonts/vera_mo_bd.ttf" +material: "/builtins/fonts/font.material" +size: 32 +antialias: 1 +alpha: 1.0 +outline_alpha: 0.0 +outline_width: 0.0 +shadow_alpha: 0.0 +shadow_blur: 0 +shadow_x: 0.0 +shadow_y: 0.0 +extra_characters: "" +output_format: TYPE_BITMAP +all_chars: false +cache_width: 0 +cache_height: 0 +render_mode: MODE_SINGLE_LAYER diff --git a/libs/narrator/example-defold/example.gui b/libs/narrator/example-defold/example.gui new file mode 100644 index 0000000..84b16e1 --- /dev/null +++ b/libs/narrator/example-defold/example.gui @@ -0,0 +1,146 @@ +script: "/example-defold/example.gui_script" +fonts { + name: "example" + font: "/example-defold/example.font" +} +background_color { + x: 0.0 + y: 0.0 + z: 0.0 + w: 0.0 +} +nodes { + position { + x: 320.0 + y: 340.0 + z: 0.0 + w: 1.0 + } + rotation { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + scale { + x: 0.5 + y: 0.5 + z: 0.5 + w: 1.0 + } + size { + x: 800.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + color { + x: 0.102 + y: 0.102 + z: 0.102 + w: 1.0 + } + type: TYPE_TEXT + blend_mode: BLEND_MODE_ALPHA + text: "paragraph" + font: "example" + id: "paragraph" + xanchor: XANCHOR_NONE + yanchor: YANCHOR_NONE + pivot: PIVOT_N + outline { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + shadow { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + adjust_mode: ADJUST_MODE_FIT + line_break: true + layer: "" + inherit_alpha: true + alpha: 1.0 + outline_alpha: 1.0 + shadow_alpha: 1.0 + template_node_child: false + text_leading: 1.0 + text_tracking: 0.0 + custom_type: 0 + enabled: false + visible: true + material: "" +} +nodes { + position { + x: 320.0 + y: 300.0 + z: 0.0 + w: 1.0 + } + rotation { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + scale { + x: 0.5 + y: 0.5 + z: 0.5 + w: 1.0 + } + size { + x: 800.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + color { + x: 0.6 + y: 0.2 + z: 0.0 + w: 1.0 + } + type: TYPE_TEXT + blend_mode: BLEND_MODE_ALPHA + text: "choice" + font: "example" + id: "choice" + xanchor: XANCHOR_NONE + yanchor: YANCHOR_NONE + pivot: PIVOT_N + outline { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + shadow { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + adjust_mode: ADJUST_MODE_FIT + line_break: true + layer: "" + inherit_alpha: true + alpha: 1.0 + outline_alpha: 1.0 + shadow_alpha: 1.0 + template_node_child: false + text_leading: 1.0 + text_tracking: 0.0 + custom_type: 0 + enabled: false + visible: true + material: "" +} +material: "/builtins/materials/gui.material" +adjust_reference: ADJUST_REFERENCE_PARENT +max_nodes: 512 diff --git a/libs/narrator/example-defold/example.gui_script b/libs/narrator/example-defold/example.gui_script new file mode 100644 index 0000000..f28bf0c --- /dev/null +++ b/libs/narrator/example-defold/example.gui_script @@ -0,0 +1,167 @@ +local narrator = require('narrator.narrator') + +---@class Self +---@field paragraph_template node +---@field paragraph_initial_position vector3 +---@field paragraph_nodes node[] +---@field choice_template node +---@field choice_initial_position vector3 +---@field choice_nodes node[] +---@field book Narrator.Book +---@field story Narrator.Story + +---@param node node +---@param text string +---@return integer height +local function estimated_height(node, text) + local font_name = gui.get_font(node) + local font = gui.get_font_resource(font_name) + + local metrics = resource.get_text_metrics(font, text, { + width = gui.get_size(node).x, + line_break = true + }) + + return metrics.height +end + +---@param self Self +local function clear_output(self) + for index = #self.paragraph_nodes, 1, -1 do + local node = self.paragraph_nodes[index] + table.remove(self.paragraph_nodes, index) + + gui.animate(node, 'color.w', 0, gui.EASING_LINEAR, 0.5, 0, function(_, node) + gui.delete_node(node) + end) + end + + for index = #self.choice_nodes, 1, -1 do + local node = self.choice_nodes[index] + table.remove(self.choice_nodes, index) + + gui.animate(node, 'color.w', 0, gui.EASING_LINEAR, 0.5, 0, function(_, node) + gui.delete_node(node) + end) + end +end + +---@param self Self +local function display_output(self) + clear_output(self) + + -- Pull all the paragraphs + local paragraphs = self.story:continue() + + -- Get the available choices + local choices = self.story:get_choices() + + local paragraph_position = vmath.vector3(self.paragraph_initial_position) + local choice_position = vmath.vector3(self.choice_initial_position) + + for index = #paragraphs, 1, -1 do + local paragraph = paragraphs[index] + local node = gui.clone(self.paragraph_template) + gui.set_text(node, paragraph.text) + + local height = estimated_height(node, paragraph.text) + if index < #paragraphs then + paragraph_position.y = paragraph_position.y + height / 2 + 16 + end + + gui.set_position(node, paragraph_position) + gui.set_enabled(node, true) + gui.set_alpha(node, 0) + gui.animate(node, 'color.w', 1, go.EASING_LINEAR, 0.5, index / 2) + + table.insert(self.paragraph_nodes, node) + end + + for index = 1, #choices do + local choice = choices[index] + local node = gui.clone(self.choice_template) + gui.set_text(node, choice.text) + + local height = estimated_height(node, choice.text) + if index > 1 then + choice_position.y = choice_position.y - height / 2 - 16 + end + + gui.set_enabled(node, true) + gui.set_position(node, choice_position) + + local size = gui.get_size(node) + size.y = height + gui.set_size(node, size) + + gui.set_alpha(node, 0) + gui.animate(node, 'color.w', 1, go.EASING_LINEAR, 0.5, (#paragraphs + 1) / 2) + + table.insert(self.choice_nodes, node) + end +end + +---@param self Self +function init(self) + msg.post('@render:', 'clear_color', { color = vmath.vector4(1, 1, 1, 1)}) + msg.post('.', 'acquire_input_focus') + + self.paragraph_template = gui.get_node('paragraph') + self.paragraph_initial_position = gui.get_position(self.paragraph_template) + self.paragraph_nodes = {} + + self.choice_template = gui.get_node('choice') + self.choice_initial_position = gui.get_position(self.choice_template) + self.choice_nodes = {} + + -- Parse and save a book + self.book = narrator.parse_file('example-defold.book', { save = true }) + + -- Or load and parse the Ink file from the custom resources + -- local content = sys.load_resource('/example-defold/book.ink') + -- self.book = narrator.parseBook(content) + + -- Or load a book from the saved lua module + -- self.book = require('example-defold.book') + + -- Or parse a book from the string with Ink content + -- self.book = narrator.parseBook('Hello world!') + + -- Init a story + self.story = narrator.init_story(self.book) + + -- Begin the story + self.story:begin() + + display_output(self) +end + +---@param self Self +---@param action_id hash|string +---@param action table +function on_input(self, action_id, action) + if action_id ~= hash 'touch' or not action.pressed then + return + end + + if not self.story:can_choose() then + -- Begin a new story + self.story = narrator.init_story(self.book) + self.story:begin() + + display_output(self) + + return true + end + + for index, node in ipairs(self.choice_nodes) do + if gui.pick_node(node, action.x, action.y) then + -- Make a choice + self.story:choose(index) + + display_output(self) + + return true + end + end +end \ No newline at end of file diff --git a/libs/narrator/game.lua b/libs/narrator/game.lua new file mode 100644 index 0000000..2199fee --- /dev/null +++ b/libs/narrator/game.lua @@ -0,0 +1,60 @@ +-- +-- A simple command-line game example + +-- Dependencies +local narrator = require('narrator.narrator') + +-- Parse a book from the Ink file and save as module 'stories.game.lua' +local book = narrator.parse_file('stories.game', { save = true }) + +-- Init a story from the book +local story = narrator.init_story(book) + +-- Start observing the Ink variable 'x' +story:observe('x', function(x) print('The x did change! Now it\'s ' .. x) end) + +-- Bind local functions to call from ink as external functions +story:bind('beep', function() print('Beep! 😃') end) +story:bind('sum', function(x, y) return x + y end) + +-- Begin the story +story:begin() + +print('--- Game started ---\n') + +while story:can_continue() do + + -- Get current paragraphs to output + local paragraphs = story:continue() + + for _, paragraph in ipairs(paragraphs) do + local text = paragraph.text + + -- You can handle tags as you like, but we attach them to text here. + if paragraph.tags then + text = text .. ' #' .. table.concat(paragraph.tags, ' #') + end + + -- Output text to the player + print(text) + end + + -- If there is no choice, it seems the game is over + if not story:can_choose() then break end + print('') + + -- Get available choices and output them to the player + local choices = story:get_choices() + for i, choice in ipairs(choices) do + print(i .. ') ' .. choice.text) + end + + -- Read the choice from the player input + local answer = tonumber(io.read()) or 0 + print('') + + -- Send an answer to the story to generate new paragraphs + story:choose(answer) +end + +print('\n--- Game over ---') \ No newline at end of file diff --git a/libs/narrator/game.project b/libs/narrator/game.project new file mode 100644 index 0000000..48b4d35 --- /dev/null +++ b/libs/narrator/game.project @@ -0,0 +1,25 @@ +[bootstrap] +main_collection = /example-defold/examlpe.collectionc +render = /builtins/render/default.renderc + +[script] +shared_state = 1 + +[display] +width = 640 +height = 640 + +[android] +input_method = HiddenInputField + +[project] +title = Narrator +custom_resources = example-defold/book.ink +dependencies#0 = https://github.com/astrochili/defold-lpeg/archive/1.0.4.zip + +[library] +include_dirs = narrator + +[input] +game_binding = /builtins/input/all.input_bindingc + diff --git a/libs/narrator/narrator/annotations.lua b/libs/narrator/narrator/annotations.lua new file mode 100644 index 0000000..f82f3a6 --- /dev/null +++ b/libs/narrator/narrator/annotations.lua @@ -0,0 +1,37 @@ +---@class Narrator.Book.Version +---@field engine number +---@field tree number + +---@class Narrator.Book +---@field version Narrator.Book.Version +---@field inclusions string[] +---@field lists table +---@field constants table +---@field variables table +---@field params table +---@field tree table + +---@class Narrator.ParsingParams +---@field save boolean Save a parsed book to the lua file + +---@class Narrator.Paragraph +---@field text string +---@field tags string[]|nil + +---@class Narrator.Choice +---@field text string +---@field tags string[]|nil + +---@class Narrator.State +---@field version number +---@field temp table +---@field seeds table +---@field variables table +---@field params table|nil +---@field visits table +---@field current_path table +---@field paragraphs table +---@field choices table +---@field output table +---@field tunnels table|nil +---@field path table \ No newline at end of file diff --git a/libs/narrator/narrator/enums.lua b/libs/narrator/narrator/enums.lua new file mode 100644 index 0000000..5fc62df --- /dev/null +++ b/libs/narrator/narrator/enums.lua @@ -0,0 +1,32 @@ +local enums = { + + ---Bump it when the state structure is changed + engine_version = 2, + + ---@enum Narrator.ItemType + item = { + text = 1, + alts = 2, + choice = 3, + condition = 4, + variable = 5 + }, + + ---@enum Narrator.Sequence + sequence = { + cycle = 1, + stopping = 2, + once = 3 + }, + + ---@enum Narrator.ReadMode + read_mode = { + text = 1, + choices = 2, + gathers = 3, + quit = 4 + } + +} + +return enums \ No newline at end of file diff --git a/libs/narrator/narrator/libs/classic.lua b/libs/narrator/narrator/libs/classic.lua new file mode 100755 index 0000000..cbd6f81 --- /dev/null +++ b/libs/narrator/narrator/libs/classic.lua @@ -0,0 +1,68 @@ +-- +-- classic +-- +-- Copyright (c) 2014, rxi +-- +-- This module is free software; you can redistribute it and/or modify it under +-- the terms of the MIT license. See LICENSE for details. +-- + + +local Object = {} +Object.__index = Object + + +function Object:new() +end + + +function Object:extend() + local cls = {} + for k, v in pairs(self) do + if k:find("__") == 1 then + cls[k] = v + end + end + cls.__index = cls + cls.super = self + setmetatable(cls, self) + return cls +end + + +function Object:implement(...) + for _, cls in pairs({...}) do + for k, v in pairs(cls) do + if self[k] == nil and type(v) == "function" then + self[k] = v + end + end + end +end + + +function Object:is(T) + local mt = getmetatable(self) + while mt do + if mt == T then + return true + end + mt = getmetatable(mt) + end + return false +end + + +function Object:__tostring() + return "Object" +end + + +function Object:__call(...) + local obj = setmetatable({}, self) + obj:new(...) + return obj +end + + +return Object diff --git a/libs/narrator/narrator/libs/lume.lua b/libs/narrator/narrator/libs/lume.lua new file mode 100755 index 0000000..2157891 --- /dev/null +++ b/libs/narrator/narrator/libs/lume.lua @@ -0,0 +1,780 @@ +-- +-- lume +-- +-- Copyright (c) 2020 rxi +-- +-- 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 lume = { _version = "2.3.0" } + +local pairs, ipairs = pairs, ipairs +local type, assert, unpack = type, assert, unpack or table.unpack +local tostring, tonumber = tostring, tonumber +local math_floor = math.floor +local math_ceil = math.ceil +local math_atan2 = math.atan2 or math.atan +local math_sqrt = math.sqrt +local math_abs = math.abs + +local noop = function() +end + +local identity = function(x) + return x +end + +local patternescape = function(str) + return str:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1") +end + +local absindex = function(len, i) + return i < 0 and (len + i + 1) or i +end + +local iscallable = function(x) + if type(x) == "function" then return true end + local mt = getmetatable(x) + return mt and mt.__call ~= nil +end + +local getiter = function(x) + if lume.isarray(x) then + return ipairs + elseif type(x) == "table" then + return pairs + end + error("expected table", 3) +end + +local iteratee = function(x) + if x == nil then return identity end + if iscallable(x) then return x end + if type(x) == "table" then + return function(z) + for k, v in pairs(x) do + if z[k] ~= v then return false end + end + return true + end + end + return function(z) return z[x] end +end + + + +function lume.clamp(x, min, max) + return x < min and min or (x > max and max or x) +end + + +function lume.round(x, increment) + if increment then return lume.round(x / increment) * increment end + return x >= 0 and math_floor(x + .5) or math_ceil(x - .5) +end + + +function lume.sign(x) + return x < 0 and -1 or 1 +end + + +function lume.lerp(a, b, amount) + return a + (b - a) * lume.clamp(amount, 0, 1) +end + + +function lume.smooth(a, b, amount) + local t = lume.clamp(amount, 0, 1) + local m = t * t * (3 - 2 * t) + return a + (b - a) * m +end + + +function lume.pingpong(x) + return 1 - math_abs(1 - x % 2) +end + + +function lume.distance(x1, y1, x2, y2, squared) + local dx = x1 - x2 + local dy = y1 - y2 + local s = dx * dx + dy * dy + return squared and s or math_sqrt(s) +end + + +function lume.angle(x1, y1, x2, y2) + return math_atan2(y2 - y1, x2 - x1) +end + + +function lume.vector(angle, magnitude) + return math.cos(angle) * magnitude, math.sin(angle) * magnitude +end + + +function lume.random(a, b) + if not a then a, b = 0, 1 end + if not b then b = 0 end + return a + math.random() * (b - a) +end + + +function lume.randomchoice(t) + return t[math.random(#t)] +end + + +function lume.weightedchoice(t) + local sum = 0 + for _, v in pairs(t) do + assert(v >= 0, "weight value less than zero") + sum = sum + v + end + assert(sum ~= 0, "all weights are zero") + local rnd = lume.random(sum) + for k, v in pairs(t) do + if rnd < v then return k end + rnd = rnd - v + end +end + + +function lume.isarray(x) + return type(x) == "table" and x[1] ~= nil +end + + +function lume.push(t, ...) + local n = select("#", ...) + for i = 1, n do + t[#t + 1] = select(i, ...) + end + return ... +end + + +function lume.remove(t, x) + local iter = getiter(t) + for i, v in iter(t) do + if v == x then + if lume.isarray(t) then + table.remove(t, i) + break + else + t[i] = nil + break + end + end + end + return x +end + + +function lume.clear(t) + local iter = getiter(t) + for k in iter(t) do + t[k] = nil + end + return t +end + + +function lume.extend(t, ...) + for i = 1, select("#", ...) do + local x = select(i, ...) + if x then + for k, v in pairs(x) do + t[k] = v + end + end + end + return t +end + + +function lume.shuffle(t) + local rtn = {} + for i = 1, #t do + local r = math.random(i) + if r ~= i then + rtn[i] = rtn[r] + end + rtn[r] = t[i] + end + return rtn +end + + +function lume.sort(t, comp) + local rtn = lume.clone(t) + if comp then + if type(comp) == "string" then + table.sort(rtn, function(a, b) return a[comp] < b[comp] end) + else + table.sort(rtn, comp) + end + else + table.sort(rtn) + end + return rtn +end + + +function lume.array(...) + local t = {} + for x in ... do t[#t + 1] = x end + return t +end + + +function lume.each(t, fn, ...) + local iter = getiter(t) + if type(fn) == "string" then + for _, v in iter(t) do v[fn](v, ...) end + else + for _, v in iter(t) do fn(v, ...) end + end + return t +end + + +function lume.map(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + local rtn = {} + for k, v in iter(t) do rtn[k] = fn(v) end + return rtn +end + + +function lume.all(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + for _, v in iter(t) do + if not fn(v) then return false end + end + return true +end + + +function lume.any(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + for _, v in iter(t) do + if fn(v) then return true end + end + return false +end + + +function lume.reduce(t, fn, first) + local started = first ~= nil + local acc = first + local iter = getiter(t) + for _, v in iter(t) do + if started then + acc = fn(acc, v) + else + acc = v + started = true + end + end + assert(started, "reduce of an empty table with no first value") + return acc +end + + +function lume.unique(t) + local rtn = {} + for k in pairs(lume.invert(t)) do + rtn[#rtn + 1] = k + end + return rtn +end + + +function lume.filter(t, fn, retainkeys) + fn = iteratee(fn) + local iter = getiter(t) + local rtn = {} + if retainkeys then + for k, v in iter(t) do + if fn(v) then rtn[k] = v end + end + else + for _, v in iter(t) do + if fn(v) then rtn[#rtn + 1] = v end + end + end + return rtn +end + + +function lume.reject(t, fn, retainkeys) + fn = iteratee(fn) + local iter = getiter(t) + local rtn = {} + if retainkeys then + for k, v in iter(t) do + if not fn(v) then rtn[k] = v end + end + else + for _, v in iter(t) do + if not fn(v) then rtn[#rtn + 1] = v end + end + end + return rtn +end + + +function lume.merge(...) + local rtn = {} + for i = 1, select("#", ...) do + local t = select(i, ...) + local iter = getiter(t) + for k, v in iter(t) do + rtn[k] = v + end + end + return rtn +end + + +function lume.concat(...) + local rtn = {} + for i = 1, select("#", ...) do + local t = select(i, ...) + if t ~= nil then + local iter = getiter(t) + for _, v in iter(t) do + rtn[#rtn + 1] = v + end + end + end + return rtn +end + + +function lume.find(t, value) + local iter = getiter(t) + for k, v in iter(t) do + if v == value then return k end + end + return nil +end + + +function lume.match(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + for k, v in iter(t) do + if fn(v) then return v, k end + end + return nil +end + + +function lume.count(t, fn) + local count = 0 + local iter = getiter(t) + if fn then + fn = iteratee(fn) + for _, v in iter(t) do + if fn(v) then count = count + 1 end + end + else + if lume.isarray(t) then + return #t + end + for _ in iter(t) do count = count + 1 end + end + return count +end + + +function lume.slice(t, i, j) + i = i and absindex(#t, i) or 1 + j = j and absindex(#t, j) or #t + local rtn = {} + for x = i < 1 and 1 or i, j > #t and #t or j do + rtn[#rtn + 1] = t[x] + end + return rtn +end + + +function lume.first(t, n) + if not n then return t[1] end + return lume.slice(t, 1, n) +end + + +function lume.last(t, n) + if not n then return t[#t] end + return lume.slice(t, -n, -1) +end + + +function lume.invert(t) + local rtn = {} + for k, v in pairs(t) do rtn[v] = k end + return rtn +end + + +function lume.pick(t, ...) + local rtn = {} + for i = 1, select("#", ...) do + local k = select(i, ...) + rtn[k] = t[k] + end + return rtn +end + + +function lume.keys(t) + local rtn = {} + local iter = getiter(t) + for k in iter(t) do rtn[#rtn + 1] = k end + return rtn +end + + +function lume.clone(t) + local rtn = {} + for k, v in pairs(t) do rtn[k] = v end + return rtn +end + + +function lume.fn(fn, ...) + assert(iscallable(fn), "expected a function as the first argument") + local args = { ... } + return function(...) + local a = lume.concat(args, { ... }) + return fn(unpack(a)) + end +end + + +function lume.once(fn, ...) + local f = lume.fn(fn, ...) + local done = false + return function(...) + if done then return end + done = true + return f(...) + end +end + + +local memoize_fnkey = {} +local memoize_nil = {} + +function lume.memoize(fn) + local cache = {} + return function(...) + local c = cache + for i = 1, select("#", ...) do + local a = select(i, ...) or memoize_nil + c[a] = c[a] or {} + c = c[a] + end + c[memoize_fnkey] = c[memoize_fnkey] or {fn(...)} + return unpack(c[memoize_fnkey]) + end +end + + +function lume.combine(...) + local n = select('#', ...) + if n == 0 then return noop end + if n == 1 then + local fn = select(1, ...) + if not fn then return noop end + assert(iscallable(fn), "expected a function or nil") + return fn + end + local funcs = {} + for i = 1, n do + local fn = select(i, ...) + if fn ~= nil then + assert(iscallable(fn), "expected a function or nil") + funcs[#funcs + 1] = fn + end + end + return function(...) + for _, f in ipairs(funcs) do f(...) end + end +end + + +function lume.call(fn, ...) + if fn then + return fn(...) + end +end + + +function lume.time(fn, ...) + local start = os.clock() + local rtn = {fn(...)} + return (os.clock() - start), unpack(rtn) +end + + +local lambda_cache = {} + +function lume.lambda(str) + if not lambda_cache[str] then + local args, body = str:match([[^([%w,_ ]-)%->(.-)$]]) + assert(args and body, "bad string lambda") + local s = "return function(" .. args .. ")\nreturn " .. body .. "\nend" + lambda_cache[str] = lume.dostring(s) + end + return lambda_cache[str] +end + + +local serialize + +local serialize_map = { + [ "boolean" ] = tostring, + [ "nil" ] = tostring, + [ "string" ] = function(v) return string.format("%q", v) end, + [ "number" ] = function(v) + if v ~= v then return "0/0" -- nan + elseif v == 1 / 0 then return "1/0" -- inf + elseif v == -1 / 0 then return "-1/0" end -- -inf + return tostring(v) + end, + [ "table" ] = function(t, stk) + stk = stk or {} + if stk[t] then error("circular reference") end + local rtn = {} + stk[t] = true + for k, v in pairs(t) do + rtn[#rtn + 1] = "[" .. serialize(k, stk) .. "]=" .. serialize(v, stk) + end + stk[t] = nil + return "{" .. table.concat(rtn, ",") .. "}" + end +} + +setmetatable(serialize_map, { + __index = function(_, k) error("unsupported serialize type: " .. k) end +}) + +serialize = function(x, stk) + return serialize_map[type(x)](x, stk) +end + +function lume.serialize(x) + return serialize(x) +end + + +function lume.deserialize(str) + return lume.dostring("return " .. str) +end + + +function lume.split(str, sep) + if not sep then + return lume.array(str:gmatch("([%S]+)")) + else + assert(sep ~= "", "empty separator") + local psep = patternescape(sep) + return lume.array((str..sep):gmatch("(.-)("..psep..")")) + end +end + + +function lume.trim(str, chars) + if not chars then return str:match("^[%s]*(.-)[%s]*$") end + chars = patternescape(chars) + return str:match("^[" .. chars .. "]*(.-)[" .. chars .. "]*$") +end + + +function lume.wordwrap(str, limit) + limit = limit or 72 + local check + if type(limit) == "number" then + check = function(s) return #s >= limit end + else + check = limit + end + local rtn = {} + local line = "" + for word, spaces in str:gmatch("(%S+)(%s*)") do + local s = line .. word + if check(s) then + table.insert(rtn, line .. "\n") + line = word + else + line = s + end + for c in spaces:gmatch(".") do + if c == "\n" then + table.insert(rtn, line .. "\n") + line = "" + else + line = line .. c + end + end + end + table.insert(rtn, line) + return table.concat(rtn) +end + + +function lume.format(str, vars) + if not vars then return str end + local f = function(x) + return tostring(vars[x] or vars[tonumber(x)] or "{" .. x .. "}") + end + return (str:gsub("{(.-)}", f)) +end + + +function lume.trace(...) + local info = debug.getinfo(2, "Sl") + local t = { info.short_src .. ":" .. info.currentline .. ":" } + for i = 1, select("#", ...) do + local x = select(i, ...) + if type(x) == "number" then + x = string.format("%g", lume.round(x, .01)) + end + t[#t + 1] = tostring(x) + end + print(table.concat(t, " ")) +end + + +function lume.dostring(str) + return assert((loadstring or load)(str))() +end + + +function lume.uuid() + local fn = function(x) + local r = math.random(16) - 1 + r = (x == "x") and (r + 1) or (r % 4) + 9 + return ("0123456789abcdef"):sub(r, r) + end + return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn)) +end + + +function lume.hotswap(modname) + local oldglobal = lume.clone(_G) + local updated = {} + local function update(old, new) + if updated[old] then return end + updated[old] = true + local oldmt, newmt = getmetatable(old), getmetatable(new) + if oldmt and newmt then update(oldmt, newmt) end + for k, v in pairs(new) do + if type(v) == "table" then update(old[k], v) else old[k] = v end + end + end + local err = nil + local function onerror(e) + for k in pairs(_G) do _G[k] = oldglobal[k] end + err = lume.trim(e) + end + local ok, oldmod = pcall(require, modname) + oldmod = ok and oldmod or nil + xpcall(function() + package.loaded[modname] = nil + local newmod = require(modname) + if type(oldmod) == "table" then update(oldmod, newmod) end + for k, v in pairs(oldglobal) do + if v ~= _G[k] and type(v) == "table" then + update(v, _G[k]) + _G[k] = v + end + end + end, onerror) + package.loaded[modname] = oldmod + if err then return nil, err end + return oldmod +end + + +local ripairs_iter = function(t, i) + i = i - 1 + local v = t[i] + if v ~= nil then + return i, v + end +end + +function lume.ripairs(t) + return ripairs_iter, t, (#t + 1) +end + + +function lume.color(str, mul) + mul = mul or 1 + local r, g, b, a + r, g, b = str:match("#(%x%x)(%x%x)(%x%x)") + if r then + r = tonumber(r, 16) / 0xff + g = tonumber(g, 16) / 0xff + b = tonumber(b, 16) / 0xff + a = 1 + elseif str:match("rgba?%s*%([%d%s%.,]+%)") then + local f = str:gmatch("[%d.]+") + r = (f() or 0) / 0xff + g = (f() or 0) / 0xff + b = (f() or 0) / 0xff + a = f() or 1 + else + error(("bad color string '%s'"):format(str)) + end + return r * mul, g * mul, b * mul, a * mul +end + + +local chain_mt = {} +chain_mt.__index = lume.map(lume.filter(lume, iscallable, true), + function(fn) + return function(self, ...) + self._value = fn(self._value, ...) + return self + end + end) +chain_mt.__index.result = function(x) return x._value end + +function lume.chain(value) + return setmetatable({ _value = value }, chain_mt) +end + +setmetatable(lume, { + __call = function(_, ...) + return lume.chain(...) + end +}) + + +return lume diff --git a/libs/narrator/narrator/list/mt.lua b/libs/narrator/narrator/list/mt.lua new file mode 100644 index 0000000..27d90f7 --- /dev/null +++ b/libs/narrator/narrator/list/mt.lua @@ -0,0 +1,401 @@ +-- +-- Dependencies + +local lume = require('narrator.libs.lume') + +-- +-- Metatable + +local mt = { lists = { } } + +function mt.__tostring(self) + local pool = { } + + local list_keys = { } + for key, _ in pairs(self) do + table.insert(list_keys, key) + end + table.sort(list_keys) + + for i = 1, #list_keys do + local list_name = list_keys[i] + local list_items = self[list_name] + for index = 1, #mt.lists[list_name] do + pool[index] = pool[index] or { } + local item_name = mt.lists[list_name][index] + if list_items[item_name] == true then + table.insert(pool[index], 1, item_name) + end + end + end + + local items = { } + + for _, titles in ipairs(pool) do + for _, title in ipairs(titles) do + table.insert(items, title) + end + end + + return table.concat(items, ', ') +end + +-- +-- Operators + +function mt.__add(lhs, rhs) -- + + if type(rhs) == 'table' then + return mt.__add_list(lhs, rhs) + elseif type(rhs) == 'number' then + return mt.__shift_by_number(lhs, rhs) + else + error('Attempt to sum the list with ' .. type(rhs)) + end +end + +function mt.__sub(lhs, rhs) -- - + if type(rhs) == 'table' then + return mt.__subList(lhs, rhs) + elseif type(rhs) == 'number' then + return mt.__shift_by_number(lhs, -rhs) + else + error('Attempt to sub the list with ' .. type(rhs)) + end +end + +function mt.__mod(lhs, rhs) -- % (contain) + if type(rhs) ~= 'table' then + error('Attempt to check content of the list for ' .. type(rhs)) + end + + for list_name, list_items in pairs(rhs) do + if lhs[list_name] == nil then return false end + for item_name, item_value in pairs(list_items) do + if (lhs[list_name][item_name] or false) ~= item_value then return false end + end + end + + return true +end + +function mt.__pow(lhs, rhs) -- ^ (intersection) + if type(rhs) ~= 'table' then + error('Attempt to interselect the list with ' .. type(rhs)) + end + + local intersection = { } + + for list_name, list_items in pairs(lhs) do + for item_name, item_value in pairs(list_items) do + local left = lhs[list_name][item_name] + local right = (rhs[list_name] or { })[item_name] + if left == true and right == true then + intersection[list_name] = intersection[list_name] or { } + intersection[list_name][item_name] = true + end + end + end + + setmetatable(intersection, mt) + return intersection +end + +function mt.__len(self) -- # + local len = 0 + + for list_name, list_items in pairs(self) do + for item_name, item_value in pairs(list_items) do + if item_value == true then len = len + 1 end + end + end + + return len +end + +function mt.__eq(lhs, rhs) -- == + if type(rhs) ~= 'table' then + error('Attempt to compare the list with ' .. type(rhs)) + end + + local function keys_count(object) + local count = 0 + for _, _ in pairs(object) do + count = count + 1 + end + return count + end + + local left_lists_count = keys_count(lhs) + local right_lists_count = keys_count(rhs) + if left_lists_count ~= right_lists_count then + return false + end + + for list_name, left_items in pairs(lhs) do + local right_items = rhs[list_name] + if right_items == nil then + return false + end + + local left_items_count = keys_count(left_items) + local right_items_count = keys_count(right_items) + + if left_items_count ~= right_items_count then + return false + end + end + + return mt.__mod(lhs, rhs) +end + +function mt.__lt(lhs, rhs) -- < + if type(rhs) ~= 'table' then + error('Attempt to compare the list with ' .. type(rhs)) + end + + -- LEFT < RIGHT means "the smallest value in RIGHT is bigger than the largest values in LEFT" + + local minLeft = mt.min_value_of(lhs, true) + local maxRight = mt.max_value_of(rhs, true) + + return minLeft < maxRight +end + +function mt.__le(lhs, rhs) -- <= + if type(rhs) ~= 'table' then + error('Attempt to compare the list with ' .. type(rhs)) + end + + -- LEFT => RIGHT means "the smallest value in RIGHT is at least the smallest value in LEFT, + -- and the largest value in RIGHT is at least the largest value in LEFT". + + local minRight = mt.min_value_of(rhs, true) + local minLeft = mt.min_value_of(lhs, true) + local maxRight = mt.max_value_of(rhs, true) + local maxLeft = mt.max_value_of(lhs, true) + + return minRight >= minLeft and maxRight >= maxLeft +end + +-- +-- Custom operators + +function mt.__add_list(lhs, rhs) + local result = lume.clone(lhs) + + for list_name, list_items in pairs(rhs) do + result[list_name] = result[list_name] or { } + for item_name, item_value in pairs(list_items) do + result[list_name][item_name] = item_value + end + end + + return result +end + +function mt.__subList(lhs, rhs) + local result = lume.clone(lhs) + + for list_name, list_items in pairs(rhs) do + if lhs[list_name] ~= nil then + for item_name, _ in pairs(list_items) do + lhs[list_name][item_name] = nil + end + end + end + + return mt.remove_empties_in_list(result) +end + +function mt.__shift_by_number(list, number) + local result = { } + + for list_name, list_items in pairs(list) do + result[list_name] = { } + for index, item_name in ipairs(mt.lists[list_name]) do + if list_items[item_name] == true then + local nextItem = mt.lists[list_name][index + number] + if nextItem ~= nil then + result[list_name][nextItem] = true + end + end + end + end + + return mt.remove_empties_in_list(result) +end + +-- +-- Helpers + +function mt.remove_empties_in_list(list) + local result = lume.clone(list) + + for list_name, list_items in pairs(list) do + if next(list_items) == nil then + result[list_name] = nil + end + end + + return result +end + +function mt.min_value_of(list, raw) + local min_index = 0 + local min_value = { } + + local list_keys = { } + for key, _ in pairs(list) do + table.insert(list_keys, key) + end + table.sort(list_keys) + + for i = 1, #list_keys do + local list_name = list_keys[i] + local list_items = list[list_name] + for item_name, item_value in pairs(list_items) do + if item_value == true then + local index = lume.find(mt.lists[list_name], item_name) + if index and index < min_index or min_index == 0 then + min_index = index + min_value = { [list_name] = { [item_name] = true } } + end + end + end + end + + return raw and min_index or min_value +end + +function mt.max_value_of(list, raw) + local max_index = 0 + local max_value = { } + + local list_keys = { } + for key, _ in pairs(list) do + table.insert(list_keys, key) + end + table.sort(list_keys) + + for i = 1, #list_keys do + local list_name = list_keys[i] + local list_items = list[list_name] + for item_name, item_value in pairs(list_items) do + if item_value == true then + local index = lume.find(mt.lists[list_name], item_name) + if index and index > max_index or max_index == 0 then + max_index = index + max_value = { [list_name] = { [item_name] = true } } + end + end + end + end + + return raw and max_index or max_value +end + +function mt.random_value_of(list) + local items = { } + + local list_keys = { } + for key, _ in pairs(list) do + table.insert(list_keys, key) + end + table.sort(list_keys) + + for i = 1, #list_keys do + local list_name = list_keys[i] + local list_items = list[list_name] + local items_keys = { } + for key, _ in pairs(list_items) do + table.insert(items_keys, key) + end + table.sort(items_keys) + + for i = 1, #items_keys do + local item_name = items_keys[i] + local item_value = list_items[item_name] + if item_value == true then + local result = { [list_name] = { [item_name] = true } } + table.insert(items, result) + end + end + end + + local random_index = math.random(1, #items) + return items[random_index] +end + +function mt.first_raw_value_of(list) + local result = 0 + + for list_name, list_items in pairs(list) do + for item_name, item_value in pairs(list_items) do + if item_value == true then + local index = lume.find(mt.lists[list_name], item_name) + if index then + result = index + break + end + end + end + end + + return result +end + +function mt.posible_values_of(list) + local result = { } + + for list_name, list_items in pairs(list) do + local subList = { } + for _, item_name in ipairs(mt.lists[list_name]) do + subList[item_name] = true + end + result[list_name] = subList + end + + return result +end + +function mt.range_of(list, min, max) + if type(min) ~= 'table' and type(min) ~= 'number' then + error('Attempt to get a range with incorrect min value of type ' .. type(min)) + end + if type(max) ~= 'table' and type(max) ~= 'number' then + error('Attempt to get a range with incorrect max value of type ' .. type(max)) + end + + local result = { } + local allList = mt.posible_values_of(list) + local min_index = type(min) == 'number' and min or mt.first_raw_value_of(min) + local max_index = type(max) == 'number' and max or mt.first_raw_value_of(max) + + for list_name, list_items in pairs(allList) do + for item_name, item_value in pairs(list_items) do + local index = lume.find(mt.lists[list_name], item_name) + if index and index >= min_index and index <= max_index and list[list_name][item_name] == true then + result[list_name] = result[list_name] or { } + result[list_name][item_name] = true + end + end + end + + return result +end + +function mt.invert(list) + local result = mt.posible_values_of(list) + + for list_name, list_items in pairs(list) do + for item_name, item_value in pairs(list_items) do + if item_value == true then + result[list_name][item_name] = nil + end + end + end + + return result +end + +return mt \ No newline at end of file diff --git a/libs/narrator/narrator/narrator.lua b/libs/narrator/narrator/narrator.lua new file mode 100644 index 0000000..7c037d8 --- /dev/null +++ b/libs/narrator/narrator/narrator.lua @@ -0,0 +1,150 @@ +local lume = require('narrator.libs.lume') +local enums = require('narrator.enums') +local parser = require('narrator.parser') +local Story = require('narrator.story') + +-- +-- Local + +local folder_separator = package.config:sub(1, 1) + +---Clear path from '.lua' and '.ink' extensions and replace '.' to '/' or '\' +---@param path string +---@return string normalized_path +local function normalize_path(path) + local path = path:gsub('.lua$', '') + local path = path:gsub('.ink$', '') + + if path:match('%.') and not path:match(folder_separator) then + path = path:gsub('%.', folder_separator) + end + + return path +end + +---Parse an .ink file to the content string. +---@param path string +---@return string content +local function read_ink_file(path) + local path = normalize_path(path) .. '.ink' + + local file = io.open(path, 'r') + assert(file, 'File doesn\'t exist: ' .. path) + + local content = file:read('*all') + file:close() + + return content +end + +---Save a book to the lua module +---@param book Narrator.Book +---@param path string +---@return boolean success +local function save_book(book, path) + local path = normalize_path(path) .. '.lua' + + local data = lume.serialize(book) + data = data:gsub('%[%d+%]=', '') + data = data:gsub('[\'[%w_]+\']', function(match) return + match:sub(3, #match - 2) + end) + + local file = io.open(path, 'w') + if file == nil then + return false + end + + file:write('return ' .. data) + file:close() + + return true +end + +---Merge a chapter to the book +---@param book Narrator.Book +---@param chapter Narrator.Book +---@return Narrator.Book +local function merge_chapter_to_book(book, chapter) + -- Check a engine version compatibility + if chapter.version.engine and chapter.version.engine ~= enums.engine_version then + assert('Version ' .. chapter.version.engine .. ' of book isn\'t equal to the version ' .. enums.engine_version .. ' of Narrator.') + end + + --Merge the root knot and it's stitch + book.tree._._ = lume.concat(chapter.tree._._, book.tree._._) + chapter.tree._._ = nil + book.tree._ = lume.merge(chapter.tree._, book.tree._) + chapter.tree._ = nil + + --Merge a chapter to the book + book.tree = lume.merge(book.tree or { }, chapter.tree or { }) + book.constants = lume.merge(book.constants or { }, chapter.constants or { }) + book.lists = lume.merge(book.lists or { }, chapter.lists or { }) + book.variables = lume.merge(book.variables or { }, chapter.variables or { }) + book.params = lume.merge(book.params or { }, chapter.params or { }) + + return book +end + +-- +-- Public + +local narrator = { } + +---Parse a book from an Ink file +---Use it during development, but prefer already parsed and stored books in production +---Requires `lpeg` and `io`. +---@param path string +---@param params Narrator.ParsingParams|nil +---@return Narrator.Book +function narrator.parse_file(path, params) + local params = params or { save = false } + assert(parser, 'Can\'t parse anything without lpeg, sorry.') + + local content = read_ink_file(path) + local book = parser.parse(content) + + for _, inclusion in ipairs(book.inclusions) do + local folder_path = normalize_path(path):match('(.*' .. folder_separator .. ')') + local inclusion_path = folder_path .. normalize_path(inclusion) .. '.ink' + local chapter = narrator.parse_file(inclusion_path) + + merge_chapter_to_book(book, chapter) + end + + if params.save then + save_book(book, path) + end + + return book +end + +---Parse a book from the ink content string +---Use it during development, but prefer already parsed and stored books in production +---Requires `lpeg` +---@param content string +---@param inclusions string[] +---@return Narrator.Book +function narrator.parse_content(content, inclusions) + local inclusions = inclusions or { } + assert(parser, 'Can\'t parse anything without a parser.') + + local book = parser.parse(content) + + for _, inclusion in ipairs(inclusions) do + local chapter = parser.parse(inclusion) + merge_chapter_to_book(book, chapter) + end + + return book +end + +---Init a story based on the book +---@param book Narrator.Book +---@return Narrator.Story +function narrator.init_story(book) + return Story(book) +end + +return narrator \ No newline at end of file diff --git a/libs/narrator/narrator/parser.lua b/libs/narrator/narrator/parser.lua new file mode 100644 index 0000000..5d01d76 --- /dev/null +++ b/libs/narrator/narrator/parser.lua @@ -0,0 +1,789 @@ +local lume = require('narrator.libs.lume') +local enums = require('narrator.enums') + +-- +-- LPeg + +-- To allow to build in Defold +local lpeg_name = 'lpeg' + +if not pcall(require, lpeg_name) then + return false +end + +local lpeg = require(lpeg_name) + +local S, C, P, V = lpeg.S, lpeg.C, lpeg.P, lpeg.V +local Cb, Ct, Cc, Cg = lpeg.Cb, lpeg.Ct, lpeg.Cc, lpeg.Cg +local Cmt = lpeg.Cmt + +lpeg.locale(lpeg) + +-- +-- Parser + +local parser = { } +local constructor = { } + +---Parse ink content string +---@param content string +---@return Narrator.Book +function parser.parse(content) + + -- + -- Basic patterns + + local function get_length(array) return + #array + end + + local eof = -1 + local sp = S(' \t') ^ 0 + local ws = S(' \t\r\n') ^ 0 + local nl = S('\r\n') ^ 1 + local none = Cc(nil) + + local divert_sign = P'->' + local gather_mark = sp * C('-' - divert_sign) + local gather_level = Cg(Ct(gather_mark ^ 1) / get_length + none, 'level') + + local sticky_marks = Cg(Ct((sp * C('+')) ^ 1) / get_length, 'level') * Cg(Cc(true), 'sticky') + local choice_marks = Cg(Ct((sp * C('*')) ^ 1) / get_length, 'level') * Cg(Cc(false), 'sticky') + local choice_level = sticky_marks + choice_marks + + local id = (lpeg.alpha + '_') * (lpeg.alnum + '_') ^ 0 + local label = Cg('(' * sp * C(id) * sp * ')', 'label') + local address = id * ('.' * id) ^ -2 + + ---Something for tunnels + local function check_tunnel(s, i, a) + local r = lpeg.match (sp * divert_sign, s, i) + return i, r ~= nil + end + + -- TODO: Clean divert expression to divert and tunnel + local divert = divert_sign * sp * Cg(address, 'path') -- base search for divert symbol and path to follow + local check_tunnel = Cg(Cmt(Cb('path'), check_tunnel), 'tunnel') -- a weird way to to check tunnel + local opt_tunnel_sign = (sp * divert_sign * sp * (#nl + #S'#') ) ^ -1 -- tunnel sign in end of string, keep newline not consumed + divert = Cg(Ct(divert * sp * check_tunnel * opt_tunnel_sign), 'divert') + + local divert_to_nothing = divert_sign * none + local exit_tunnel = Cg(divert_sign * divert_sign, 'exit') + local tag = '#' * sp * V'text' + local tags = Cg(Ct(tag * (sp * tag) ^ 0), 'tags') + + local todo = sp * 'TODO:' * (1 - nl) ^ 0 + local comment_line = sp * '//' * sp * (1 - nl) ^ 0 + local comment_multi = sp * '/*' * ((P(1) - '*/') ^ 0) * '*/' + local comment = comment_line + comment_multi + + local multiline_end = ws * '}' + + -- + -- Dynamic patterns and evaluation helpers + + local function item_type(type) + return Cg(Cc(type), 'type') + end + + local function balanced_multiline_item(is_restricted) + local is_restricted = is_restricted ~= nil and is_restricted or false + local paragraph = is_restricted and V'restricted_paragraph' or V'paragraph' + return sp * paragraph ^ -1 * sp * V'multiline_item' * sp * paragraph ^ -1 * ws + end + + local function sentence_before(excluded, tailed) + local tailed = tailed or false + local character = P(1 - S(' \t')) - excluded + local pattern = (sp * character ^ 1) ^ 1 + local with_tail = C(pattern * sp) + local without_tail = C(pattern) * sp + local without_tail_always = C(pattern) * sp * #(tags + nl) + return without_tail_always + (tailed and with_tail or without_tail) + end + + local function unwrap_assignment(assignment) + local unwrapped = assignment + unwrapped = unwrapped:gsub('([%w_]*)%s*([%+%-])[%+%-]', '%1 = %1 %2 1') + unwrapped = unwrapped:gsub('([%w_]*)%s*([%+%-])=%s*(.*)', '%1 = %1 %2 %3') + local name, value = unwrapped:match('([%w_]*)%s*=%s*(.*)') + return name or '', value or assignment + end + + local function check_special_escape(s, i, a) + if string.sub(s, i - 2, i - 2) == '\\' then + return + end + + return i + end + + -- + -- Grammar rules + + local ink_grammar = P({ 'root', + + -- Root + + root = ws * V'items' + eof, + items = Ct(V'item' ^ 0), + + item = balanced_multiline_item() + V'singleline_item', + singleline_item = sp * (V'global' + V'statement' + V'paragraph' + V'gatherPoint') * ws, + multiline_item = ('{' * sp * (V'sequence' + V'switch') * sp * multiline_end) - V'inline_condition', + + -- Gather points + gatherPoint = Ct(gather_level * sp * nl * item_type('gather')), + + -- Global declarations + + global = + Ct(V'inclusion' * item_type('inclusion')) + + Ct(V'list' * item_type('list')) + + Ct(V'constant' * item_type('constant')) + + Ct(V'variable' * item_type('variable')) + , + + inclusion = 'INCLUDE ' * sp * Cg(sentence_before(nl + comment), 'filename'), + list = 'LIST ' * sp * V'assignment_pair', + constant = 'CONST ' * sp * V'assignment_pair', + variable = 'VAR ' * sp * V'assignment_pair', + + -- Statements + + statement = + Ct(V'return_from_func' * item_type('return')) + + Ct(V'assignment' * item_type('assignment')) + + Ct(V'func' * item_type('func')) + + Ct(V'knot' * item_type('knot')) + + Ct(V'stitch' * item_type('stitch')) + + Ct(V'choice' * item_type('choice')) + + comment + todo + , + + section_name = C(id) * sp * P'=' ^ 0, + knot = P'==' * (P'=' ^ 0) * sp * Cg(V'section_name', 'knot'), + stitch = '=' * sp * Cg(V'section_name', 'stitch'), + + func_param = sp * C(id) * sp * S','^0, + func_params = P'(' * Cg(Ct(V'func_param'^0), 'params') * P')', + function_name = P'function' * sp * Cg(id, 'name') * sp * V'func_params' * sp * P'=' ^ 0, + func = P'==' * (P'=' ^ 0) * sp * Cg(Ct(V'function_name'), 'func'), + + return_from_func = sp * '~' * sp * P('return') * sp * Cg((P(1) - nl)^0, 'value') * nl ^ 0, + + assignment = gather_level * sp * '~' * sp * V'assignment_temp' * sp * V'assignment_pair', + assignment_temp = Cg('temp' * Cc(true) + Cc(false), 'temp'), + assignment_pair = Cg(sentence_before(nl + comment) / unwrap_assignment, 'name') * Cg(Cb('name') / 2, 'value'), + + choice_condition = Cg(V'expression' + none, 'condition'), + choice_fallback = choice_level * sp * V'label_optional' * sp * V'choice_condition' * sp * (divert + divert_to_nothing) * sp * V'tags_optional', + choice_normal = choice_level * sp * V'label_optional' * sp * V'choice_condition' * sp * Cg(V'text', 'text') * divert ^ -1 * sp * V'tags_optional', + choice = V'choice_fallback' + V'choice_normal', + + -- Paragraph + + paragraph = Ct(gather_level * sp * (V'paragraph_label' + V'paragraph_text' + V'paragraph_tags') * item_type('paragraph')), + paragraph_label = label * sp * Cg(V'text_optional', 'parts') * sp * V'tags_optional', + paragraph_text = V'label_optional' * sp * Cg(V'text_complex', 'parts') * sp * V'tags_optional', + paragraph_tags = V'label_optional' * sp * Cg(V'text_optional', 'parts') * sp * tags, + + label_optional = label + none, + text_optional = V'text_complex' + none, + tags_optional = tags + none, + + text_complex = Ct((Ct( + Cg(V'inline_condition', 'condition') + + Cg(V'inline_sequence', 'sequence') + + Cg(V'expression', 'expression') + + Cg(V'text' + ' ', 'text') * (exit_tunnel ^ -1) * (divert ^ -1) + exit_tunnel + divert + ) - V'multiline_item') ^ 1), + + special_check_escape = Cmt(S("{|}"), check_special_escape), + + text = sentence_before(nl + exit_tunnel + divert + comment + tag + V'special_check_escape', true) - V'statement', + -- Inline expressions, conditions, sequences + + expression = '{' * sp * sentence_before('}' + nl) * sp * '}', + + inline_condition = '{' * sp * Ct(V'inline_if_else' + V'inline_if') * sp * '}', + inline_if = Cg(sentence_before(S':}' + nl), 'condition') * sp * ':' * sp * Cg(V'text_complex', 'success'), + inline_if_else = (V'inline_if') * sp * '|' * sp * Cg(V'text_complex', 'failure'), + + inline_alt_empty = Ct(Ct(Cg(sp * Cc'', 'text') * sp * divert ^ -1)), + inline_alt = V'text_complex' + V'inline_alt_empty', + inline_alts = Ct(((sp * V'inline_alt' * sp * '|') ^ 1) * sp * V'inline_alt'), + inline_sequence = '{' * sp * ( + '!' * sp * Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('once'), 'sequence')) + + '&' * sp * Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('cycle'), 'sequence')) + + '~' * sp * Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('stopping'), 'sequence') * Cg(Cc(true), 'shuffle')) + + Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('stopping'), 'sequence')) + ) * sp * '}', + + -- Multiline conditions and switches + + switch = Ct((V'switch_comparative' + V'switch_conditional') * item_type('switch')), + + switch_comparative = Cg(V'switch_condition', 'expression') * ws * Cg(Ct((sp * V'switch_case') ^ 1), 'cases'), + switch_conditional = Cg(Ct(V'switch_cases_headed' + V'switch_cases_only'), 'cases'), + + switch_cases_headed = V'switch_if' * ((sp * V'switch_case') ^ 0), + switch_cases_only = ws * ((sp * V'switch_case') ^ 1), + + switch_if = Ct(Cg(V'switch_condition', 'condition') * ws * Cg(Ct(V'switch_items'), 'node')), + switch_case = ('-' - divert_sign) * sp * V'switch_if', + switch_condition = sentence_before(':' + nl) * sp * ':' * sp * comment ^ -1, + switch_items = (V'restricted_item' - V'switch_case') ^ 1, + + -- Multiline sequences + + sequence = Ct((V'sequence_params' * sp * nl * sp * V'sequence_alts') * item_type('sequence')), + + sequence_params = ( + V'sequence_shuffle_optional' * sp * V'sequence_type' + + V'sequence_shuffle' * sp * V'sequence_type' + + V'sequence_shuffle' * sp * V'sequence_type_optional' + ) * sp * ':' * sp * comment ^ -1, + + sequence_shuffle_optional = V'sequence_shuffle' + Cg(Cc(false), 'shuffle'), + sequence_shuffle = Cg(P'shuffle' / function() return true end, 'shuffle'), + + sequence_type_optional = V'sequence_type' + Cg(Cc'cycle', 'sequence'), + sequence_type = Cg(P'cycle' + 'stopping' + 'once', 'sequence'), + + sequence_alts = Cg(Ct((sp * V'sequence_alt') ^ 1), 'alts'), + sequence_alt = ('-' - divert_sign) * ws * Ct(V'sequence_items'), + sequence_items = (V'restricted_item' - V'sequence_alt') ^ 1, + + -- Restricted items inside multiline items + + restricted_item = balanced_multiline_item(true) + V'restricted_singleline_item', + restricted_singleline_item = sp * (V'global' + V'restricted_statement' + V'restricted_paragraph' - multiline_end) * ws, + + restricted_statement = Ct( + V'choice' * item_type('choice') + + V'assignment' * item_type('assignment') + ) + comment + todo, + + restricted_paragraph = Ct(( + Cg(V'text_complex', 'parts') * sp * V'tags_optional' + + Cg(V'text_optional', 'parts') * sp * tags + ) * item_type('paragraph')) + + }) + + -- + -- Result + + local parsed_items = ink_grammar:match(content) + local book = constructor.construct_book(parsed_items) + return book +end + +-- +-- A book construction + +function constructor.unescape(text) + local result = text + + result = result:gsub('\\|', '|') + result = result:gsub('\\{', '{') + result = result:gsub('\\}', '}') + + return result +end + +function constructor.construct_book(items) + + local construction = { + current_knot = '_', + current_stitch = '_', + variables_to_compute = { } + } + + construction.book = { + inclusions = { }, + lists = { }, + constants = { }, + variables = { }, + params = { }, + tree = { _ = { _ = { } } } + } + + construction.book.version = { + engine = enums.engine_version, + tree = 1 + } + + construction.nodes_chain = { + construction.book.tree[construction.current_knot][construction.current_stitch] + } + + constructor.add_node(construction, items) + constructor.clear(construction.book.tree) + constructor.compute_variables(construction) + + return construction.book +end + +function constructor:add_node(items, is_restricted) + local is_restricted = is_restricted ~= nil and is_restricted or false + + for _, item in ipairs(items) do + if is_restricted then + -- Are not allowed inside multiline blocks by Ink rules: + -- a) nesting levels + -- b) choices without diverts + + item.level = nil + if item.type == 'choice' and item.divert == nil then + item.type = nil + end + end + + if item.type == 'inclusion' then + -- filename + constructor.add_inclusion(self, item.filename) + elseif item.type == 'list' then + -- name, value + constructor.add_list(self, item.name, item.value) + elseif item.type == 'constant' then + -- name, value + constructor.add_constant(self, item.name, item.value) + elseif item.type == 'variable' then + -- name, value + constructor.add_variable(self, item.name, item.value) + elseif item.type == 'func' then + -- function + constructor.add_function(self, item.func.name, item.func.params) + elseif item.type == 'knot' then + -- knot + constructor.add_knot(self, item.knot) + elseif item.type == 'stitch' then + -- stitch + constructor.add_stitch(self, item.stitch) + elseif item.type == 'switch' then + -- expression, cases + constructor.add_switch(self, item.expression, item.cases) + elseif item.type == 'sequence' then + -- sequence, shuffle, alts + constructor.add_sequence(self, item.sequence, item.shuffle, item.alts) + elseif item.type == 'assignment' then + -- level, name, value, temp + constructor.add_assignment(self, item.level, item.name, item.value, item.temp) + elseif item.type == 'return' then + constructor.add_return(self, item.value) + elseif item.type == 'paragraph' then + -- level, label, parts, tags + constructor.add_paragraph(self, item.level, item.label, item.parts, item.tags) + elseif item.type == 'gather' then + constructor.add_paragraph(self, item.level, "", nil, item.tags) + elseif item.type == 'choice' then + -- level, sticky, label, condition, text, divert, tags + constructor.add_choice(self, item.level, item.sticky, item.label, item.condition, item.text, item.divert, item.tags) + end + end +end + +function constructor:add_inclusion(filename) + table.insert(self.book.inclusions, filename) +end + +function constructor:add_list(name, value) + local items = lume.array(value:gmatch('[%w_%.]+')) + self.book.lists[name] = items + + local switched = lume.array(value:gmatch('%b()')) + switched = lume.map(switched, function(item) return item:sub(2, #item - 1) end) + self.book.variables[name] = { [name] = { } } + lume.each(switched, function(item) self.book.variables[name][name][item] = true end) +end + +function constructor:add_constant(constant, value) + local value = lume.deserialize(value) + self.book.constants[constant] = value +end + +function constructor:add_variable(variable, value) + self.variables_to_compute[variable] = value +end + +function constructor:add_function(fname, params) + local node = { } + self.book.tree[fname] = { ['_'] = node } + self.book.params[fname] = params + self.nodes_chain = { node } +end + +function constructor:add_knot(knot) + self.current_knot = knot + self.current_stitch = '_' + + local node = { } + self.book.tree[self.current_knot] = { [self.current_stitch] = node } + self.nodes_chain = { node } +end + +function constructor:add_stitch(stitch) + -- If a root stitch is empty we need to add a divert to the first stitch in the ink file. + if self.current_stitch == '_' then + local root_stitch_node = self.book.tree[self.current_knot]._ + if #root_stitch_node == 0 then + local divertItem = { divert = { path = stitch } } + table.insert(root_stitch_node, divertItem) + end + end + + self.current_stitch = stitch + + local node = { } + self.book.tree[self.current_knot][self.current_stitch] = node + self.nodes_chain = { node } +end + +function constructor:add_switch(expression, cases) + if expression then + -- Convert switch cases to comparing conditions with expression + for _, case in ipairs(cases) do + if case.condition ~= 'else' then + case.condition = expression .. '==' .. case.condition + end + end + end + + local item = { + condition = { }, + success = { } + } + + for _, case in ipairs(cases) do + if case.condition == 'else' then + local failure_node = { } + table.insert(self.nodes_chain, failure_node) + constructor.add_node(self, case.node, true) + table.remove(self.nodes_chain) + item.failure = failure_node + else + local success_node = { } + table.insert(self.nodes_chain, success_node) + constructor.add_node(self, case.node, true) + table.remove(self.nodes_chain) + table.insert(item.success, success_node) + table.insert(item.condition, case.condition) + end + end + + constructor.add_item(self, nil, item) +end + +function constructor:add_sequence(sequence, shuffle, alts) + local item = { + sequence = sequence, + shuffle = shuffle and true or nil, + alts = { } + } + + for _, alt in ipairs(alts) do + local alt_node = { } + table.insert(self.nodes_chain, alt_node) + constructor.add_node(self, alt, true) + table.remove(self.nodes_chain) + table.insert(item.alts, alt_node) + end + + constructor.add_item(self, nil, item) +end + +function constructor:add_return(value) + local item = { + return_value = value + } + + constructor.add_item(self, nil, item) +end + +function constructor:add_assignment(level, name, value, temp) + local item = { + temp = temp or nil, + var = name, + value = value + } + + constructor.add_item(self, level, item) +end + +function constructor:add_paragraph(level, label, parts, tags) + local items = constructor.convert_paragraph_parts_to_items(parts, true) + items = items or { } + + -- If the paragraph has a label or tags we need to place them as the first text item. + if label ~= nil or tags ~= nil then + local first_item + + if #items > 0 and items[1].condition == nil then + first_item = items[1] + else + first_item = { } + table.insert(items, first_item) + end + + first_item.label = label + first_item.tags = tags + end + + for _, item in ipairs(items) do + constructor.add_item(self, level, item) + end +end + +function constructor.convert_paragraph_parts_to_items(parts, is_root) + if parts == nil then return nil end + + local is_root = is_root ~= nil and is_root or false + local items = { } + local item + + for index, part in ipairs(parts) do + + if part.condition then -- Inline condition part + + item = { + condition = part.condition.condition, + success = constructor.convert_paragraph_parts_to_items(part.condition.success), + failure = constructor.convert_paragraph_parts_to_items(part.condition.failure) + } + + table.insert(items, item) + item = nil + + elseif part.sequence then -- Inline sequence part + + item = { + sequence = part.sequence.sequence, + shuffle = part.sequence.shuffle and true or nil, + alts = { } + } + + for _, alt in ipairs(part.sequence.alts) do + table.insert(item.alts, constructor.convert_paragraph_parts_to_items(alt)) + end + + table.insert(items, item) + item = nil + + else -- Text, expression and divert may be + + local is_divert_only = part.divert ~= nil and part.text == nil + + if item == nil then + item = { text = (is_root or is_divert_only) and '' or '<>' } + end + + if part.text then + item.text = item.text .. part.text:gsub('%s+', ' ') + item.text = constructor.unescape(item.text) + elseif part.expression then + item.text = item.text .. '#' .. part.expression .. '#' + end + + if part.divert or part.exit then + item.exit = part.exit and true or nil + item.divert = part.divert + item.text = #item.text > 0 and (item.text .. '<>') or nil + table.insert(items, item) + item = nil + else + local next = parts[index + 1] + local next_is_block = next and not (next.text or next.expression) + + if not next or next_is_block then + if not is_root or next_is_block then + item.text = item.text .. '<>' + end + table.insert(items, item) + item = nil + end + end + + end + end + + if is_root then + -- Add a safe prefix and suffix for correct conditions gluing + + local first_item = items[1] + if first_item.text == nil and first_item.divert == nil and first_item.exit == nil then + table.insert(items, 1, { text = '' } ) + end + + local last_item = items[#items] + if last_item.text == nil and last_item.divert == nil and last_item.exit == nil then + table.insert(items, { text = '' } ) + elseif last_item.text ~= nil and last_item.divert == nil then + last_item.text = last_item.text:gsub('(.-)%s*$', '%1') + end + end + + return items +end + +function constructor:add_choice(level, sticky, label, condition, sentence, divert, tags) + local item = { + sticky = sticky or nil, + condition = condition, + label = label, + divert = divert, + tags = tags + } + + if sentence == nil then + item.choice = 0 + else + local prefix, divider, suffix = sentence:match('(.*)%[(.*)%](.*)') + prefix = prefix or sentence + divider = divider or '' + suffix = suffix or '' + + local text = (prefix .. suffix):gsub('%s+', ' ') + local choice = (prefix .. divider):gsub('%s+', ' '):gsub('^%s*(.-)%s*$', '%1') + + if divert and #text > 0 and text:match('%S+') then + text = text .. '<>' + else + text = text:gsub('^%s*(.-)%s*$', '%1') + end + + item.text = constructor.unescape(text) + item.choice = constructor.unescape(choice) + end + + constructor.add_item(self, level, item) + + if divert == nil then + item.node = { } + table.insert(self.nodes_chain, item.node) + end +end + +function constructor:add_item(level, item) + local level = (level ~= nil and level > 0) and level or #self.nodes_chain + while #self.nodes_chain > level do + table.remove(self.nodes_chain) + end + + local node = self.nodes_chain[#self.nodes_chain] + table.insert(node, item) +end + +function constructor:compute_variable(variable, value) + local constant = self.book.constants[value] + if constant then + self.book.variables[variable] = constant + return + end + + local list_expression = value:match('%(([%s%w%.,_]*)%)') + local item_expressions = list_expression and lume.array(list_expression:gmatch('[%w_%.]+')) or { value } + local list_variable = list_expression and { } or nil + + for _, item_expression in ipairs(item_expressions) do + local list_part, item_part = item_expression:match('([%w_]+)%.([%w_]+)') + item_part = item_part or item_expression + + for list_name, list_items in pairs(self.book.lists) do + local list_is_valid = list_part == nil or list_part == list_name + local item_is_found = lume.find(list_items, item_part) + + if list_is_valid and item_is_found then + list_variable = list_variable or { } + list_variable[list_name] = list_variable[list_name] or { } + list_variable[list_name][item_part] = true + end + end + end + + if list_variable then + self.book.variables[variable] = list_variable + else + self.book.variables[variable] = lume.deserialize(value) + end +end + +function constructor:compute_variables() + for variable, value in pairs(self.variables_to_compute) do + constructor.compute_variable(self, variable, value) + end +end + +function constructor.clear(tree) + for knot, node in pairs(tree) do + for stitch, node in pairs(node) do + constructor.clear_node(node) + end + end +end + +function constructor.clear_node(node) + for index, item in ipairs(node) do + + -- Simplify text only items + if item.text ~= nil and lume.count(item) == 1 then + node[index] = item.text + end + + if item.node ~= nil then + -- Clear choice nodes + if #item.node == 0 then + item.node = nil + else + constructor.clear_node(item.node) + end + + end + + if item.success ~= nil then + -- Simplify single condition + if type(item.condition) == 'table' and #item.condition == 1 then + item.condition = item.condition[1] + end + + -- Clear success nodes + if item.success[1] ~= nil and item.success[1][1] ~= nil then + for index, success_node in ipairs(item.success) do + constructor.clear_node(success_node) + if #success_node == 1 and type(success_node[1]) == 'string' then + item.success[index] = success_node[1] + end + end + + if #item.success == 1 then + item.success = item.success[1] + end + else + constructor.clear_node(item.success) + if #item.success == 1 and type(item.success[1]) == 'string' then + item.success = item.success[1] + end + end + + -- Clear failure nodes + if item.failure ~= nil then + constructor.clear_node(item.failure) + if #item.failure == 1 and type(item.failure[1]) == 'string' then + item.failure = item.failure[1] + end + end + end + + if item.alts ~= nil then + for index, alt_node in ipairs(item.alts) do + constructor.clear_node(alt_node) + if #alt_node == 1 and type(alt_node[1]) == 'string' then + item.alts[index] = alt_node[1] + end + end + end + end +end + +return parser \ No newline at end of file diff --git a/libs/narrator/narrator/story.lua b/libs/narrator/narrator/story.lua new file mode 100644 index 0000000..ce109fd --- /dev/null +++ b/libs/narrator/narrator/story.lua @@ -0,0 +1,1253 @@ +-- +-- Dependencies + +local classic = require('narrator.libs.classic') +local lume = require('narrator.libs.lume') +local enums = require('narrator.enums') +local list_mt = require('narrator.list.mt') + +-- +-- Story + +---@class Narrator.Story +---@field global_tags string[] +---@field constants table +---@field variables table +---@field migrate fun(state: Narrator.State, old_version: number, new_version: number):Narrator.State +---@field private tree any +---@field private lists any +---@field private params any +---@field private list_mt any +---@field private version any +---@field private functions any +---@field private observers any +---@field private temp any +---@field private seeds any +---@field private choices any +---@field private paragraphs any +---@field private output any +---@field private visits any +---@field private current_path any +---@field private is_over any +---@field private tunnels any +---@field private stack any +---@field private debug_seed any +---@field private return_value any +local story = classic:extend() + +-- +-- Initialization + +---@private +---@param book Narrator.Book +function story:new(book) + self.tree = book.tree + self.constants = book.constants + self.variables = lume.clone(book.variables) + self.lists = book.lists + self.params = book.params + + self.list_mt = list_mt + self.list_mt.lists = self.lists + + self.version = book.constants.version or 0 + + ---@param state Narrator.State + ---@param old_version number + ---@param new_version number + ---@return Narrator.State + self.migrate = function(state, old_version, new_version) return state end + + self.functions = self:ink_functions() + self.observers = { } + self.global_tags = self:get_tags() + + self.temp = { } + self.seeds = { } + self.choices = { } + self.paragraphs = { } + self.output = { } + self.visits = { } + self.current_path = nil + self.is_over = false + + self.tunnels = { } + self.stack = { } +end + +-- +-- Public + +---Start a story +---Generate the first chunk of paragraphs and choices +function story:begin() + if #self.paragraphs > 0 or #self.choices > 0 then + return + end + + self:jump_path('_') +end + +---Does the story have paragraphs to output or not +---@return boolean can_continue +function story:can_continue() + return #self.paragraphs > 0 +end + +---Pull the current paragraphs from the queue. +---@param steps number|nil Count of paragraphs to pull +---@return Narrator.Paragraph[] +function story:continue(steps) + local lines = { } + + if not self:can_continue() then + return lines + end + + local steps = steps or 0 + local single_mode = steps == 1 + + steps = steps > 0 and steps or #self.paragraphs + steps = steps > #self.paragraphs and #self.paragraphs or steps + + for index = 1, steps do + local paragraph = self.paragraphs[index] + paragraph.text = paragraph.text:gsub('^%s*(.-)%s*$', '%1') + + table.insert(lines, paragraph) + table.insert(self.output, paragraph) + end + + for _ = 1, steps do + table.remove(self.paragraphs, 1) + end + + return single_mode and lines[1] or lines +end + +---Does the story have choices to output or not. +---Also returns false if there are available paragraphs to continue. +---@return boolean can_choose +function story:can_choose() + return self.choices ~= nil and #self.choices > 0 and not self:can_continue() +end + +---Returns an array of available choice titles. +---Also returns an empty array if there are available paragraphs to continue. +---@return Narrator.Choice[] +function story:get_choices() + local choices = { } + + if self:can_continue() then + return choices + end + + for _, choice in ipairs(self.choices) do + local model = { + text = choice.title, + tags = choice.tags + } + + table.insert(choices, model) + end + + return choices +end + +---Make a choice to continue the story. +---@param index number an index of the choice +function story:choose(index) + if self:can_continue() then + return + end + + if #self.tunnels > 0 then + self.tunnels[#self.tunnels].restore = true + -- we are moving to another context, so the last one should be restored on exit from tunnel + end + + local choice_is_available = index > 0 and index <= #self.choices + assert(choice_is_available, 'Choice index ' .. index .. ' out of bounds 1-' .. #self.choices) + + local choice = self.choices[index] + assert(choice, 'Choice index ' .. index .. ' out of bounds 1-' .. #self.choices) + + self.paragraphs = { } + self.choices = { } + + if choice.text and #choice.text > 0 then + local paragraph = { + text = choice.text, + tags = choice.tags + } + table.insert(self.paragraphs, paragraph) + end + + self:visit(choice.path) + + if choice.divert ~= nil then + if choice.divert.tunnel then + local context = { path = choice.path, restore = true, previous = self.current_path } + table.insert(self.tunnels, context) + end + self:jump_path(choice.divert.path) + else + self:read_path(choice.path) + end +end + +---Jump to the path +---@param path_string string a path string like 'knot.stitch.label' +function story:jump_to(path_string) + self:jump_path(path_string) +end + +---Get the number of visits for the path. +---@param path_string string a path string like 'knot.stitch.label' +---@return integer +function story:get_visits(path_string) + return self:get_visits_with_context(path_string) +end + +---Get tags for the path +---@param path_string string|nil a path string with knot or stitch +---@return string[] +function story:get_tags(path_string) + local path = self:path_from_string(path_string) + local items = self:items_for(path.knot, path.stitch) + local tags = { } + + for _, item in ipairs(items) do + if type(item) == 'table' and lume.count(item) > 1 or item.tags == nil then + break + end + + local item_tags = type(item.tags) == 'string' and { item.tags } or item.tags + tags = lume.concat(tags, item_tags) + end + + return tags +end + +---Creates a table with the story state that can be saved and loaded later. +---Use it to save the game. +---@return Narrator.State +function story:save_state() + local state = { + version = self.version, + temp = self.temp, + seeds = self.seeds, + variables = self.variables, + params = self.params, + visits = self.visits, + path = self.current_path, + paragraphs = self.paragraphs, + choices = self.choices, + output = self.output, + tunnels = self.tunnels + } + + return state +end + +---Restore the story state from the saved state. +---Use it to load the game. +---@param state Narrator.State +function story:load_state(state) + if self.version ~= state.version then + state = self.migrate(state, state.version, self.version) + end + + self.temp = state.temp + self.seeds = state.seeds + self.variables = state.variables + self.params = state.params or { } + self.visits = state.visits + self.current_path = state.path + self.paragraphs = state.paragraphs + self.choices = state.choices + self.output = state.output + self.tunnels = state.tunnels or { } +end + +---Assign an observer function to the variable's changes. +---@param variable string +---@param observer fun(variable) +function story:observe(variable, observer) + self.observers[variable] = observer +end + +---Bind a function to external calling from the Ink. +---The function can returns the value or not. +---@param func_name string +---@param handler fun(...):any +function story:bind(func_name, handler) + self.functions[func_name] = handler +end + +-- +-- Private + +---@private +function story:path_chain_for_label(path) + local label = path.label + local items = self:items_for(path.knot, path.stitch) + + -- TODO: Find a more smart solution to divert to labels + -- TODO: This works but... isn't good. + + local function find_label_chain_in_items(items) + if type(items) ~= 'table' then + return nil + end + + for index, item in ipairs(items) do + + if item.label == label then + return { index } + + elseif item.node ~= nil then + local result = find_label_chain_in_items(item.node) + + if result ~= nil then + table.insert(result, 1, index) + return result + end + + elseif item.success ~= nil then + if type(item.success) == 'table' then + local is_switch = item.success[1] ~= nil and item.success[1][1] ~= nil + local cases = is_switch and item.success or { item.success } + + for case_index, case in ipairs(cases) do + local result = find_label_chain_in_items(case) + + if result ~= nil then + table.insert(result, 1, 't' .. case_index) + table.insert(result, 1, index) + return result + end + end + end + + if type(item.failure) == 'table' then + local result = find_label_chain_in_items(item.failure) + + if result ~= nil then + table.insert(result, 1, 'f') + table.insert(result, 1, index) + return result + end + end + end + end + + return nil + end + + local chain = find_label_chain_in_items(items) + assert(chain, 'Label \'' ..path.label .. '\' not found') + return chain +end + +---@private +function story:jump_path(path_string, params) + assert(path_string, 'The path_string can\'t be nil') + + self.choices = { } + + if path_string == 'END' or path_string == 'DONE' then + self.is_over = true + return + end + + local path = self:path_from_string(path_string, self.current_path) + + if path.label ~= nil then + path.chain = self:path_chain_for_label(path) + end + + return self:read_path(path, params) +end + +---@private +function story:read_path(path, params) + assert(path, 'The reading path can\'t be nil') + + if self.is_over then + return + end + + -- Visit only the paths without labels. + -- Items with labels will increment visits counter by themself in read_items(). + if not path.label then + self:visit(path) + end + + if params then + for name, value in pairs(params) do + self:assign_value_to(name, value, true) + end + end + + local items = self:items_for(path.knot, path.stitch) + return self:read_items(items, path) +end + +---@private +function story:items_for(knot, stitch) + local root_node = self.tree + local knot_node = knot == nil and root_node._ or root_node[knot] + assert(knot_node or lume.isarray(root_node), 'The knot \'' .. (knot or '_') .. '\' not found') + + local stitch_node = stitch == nil and knot_node._ or knot_node[stitch] + assert(stitch_node or lume.isarray(knot_node), 'The stitch \'' .. (knot or '_') .. '.' .. (stitch or '_') .. '\' not found') + + return stitch_node or knot_node or root_node +end + +---@private +function story:read_items(items, path, depth, mode, current_index) + assert(items, 'Items can\'t be nil') + assert(path, 'Path can\'t be nil') + + local chain = path.chain or { } + local depth = depth or 0 + local deep_index = chain[depth + 1] + local mode = mode or enums.read_mode.text + + -- Deep path factory + + local make_deep_path = function(values, label_prefix) + local deep_chain = lume.slice(chain, 1, depth) + + for values_index, value in ipairs(values) do + deep_chain[depth + values_index] = value + end + + local deep_path = lume.clone(path) + deep_path.chain = deep_chain + + if label_prefix then + deep_path.label = label_prefix .. table.concat(deep_chain, '.') + end + + return deep_path + end + + -- Iterate items + + for index = current_index or (deep_index or 1), #items do + local context = { + items = items, + path = path, + depth = depth, + mode = mode, + index = index + 1, + previous = self.current_path + } + + local item = items[index] + local skip = false + + if item.return_value then + self.return_value = tostring(item.return_value) + return enums.read_mode.quit + end + + local item_type = enums.item.text + + if type(item) == 'table' then + if item.choice ~= nil then + item_type = enums.item.choice + elseif item.success ~= nil then + item_type = enums.item.condition + elseif item.var ~= nil then + item_type = enums.item.variable + elseif item.alts ~= nil then + item_type = enums.item.alts + end + end + + -- Go deep + if index == deep_index then + if item_type == enums.item.choice and item.node ~= nil then + -- Go deep to the choice node + mode = enums.read_mode.gathers + mode = self:read_items(item.node, path, depth + 1) or mode + + elseif item_type == enums.item.condition then + -- Go deep to the condition node + local chain_value = chain[depth + 2] + local is_success = chain_value:sub(1, 1) == 't' + local node + + if is_success then + local success_index = tonumber(chain_value:sub(2, 2)) or 0 + node = success_index > 0 and item.success[success_index] or item.success + else + node = item.failure + end + + mode = self:read_items(node, path, depth + 2, mode) or mode + end + + if item_type == enums.item.condition or item_type == enums.item.choice then + mode = mode ~= enums.read_mode.quit and enums.read_mode.gathers or mode + skip = true + end + end + + -- Check the situation + if mode == enums.read_mode.choices and item_type ~= enums.item.choice then + mode = enums.read_mode.quit + skip = true + elseif mode == enums.read_mode.gathers and item_type == enums.item.choice then + skip = true + end + + -- Read the item + if skip then + -- skip + elseif item_type == enums.item.text then + mode = enums.read_mode.text + local safe_item = type(item) == 'string' and { text = item } or item + mode = self:read_text(safe_item, context) or mode + elseif item_type == enums.item.alts then + mode = enums.read_mode.text + local deep_path = make_deep_path({ index }, '~') + mode = self:read_alts(item, deep_path, depth + 1, mode) or mode + elseif item_type == enums.item.choice and self:check_condition(item.condition) then + mode = enums.read_mode.choices + local deep_path = make_deep_path({ index }, '>') + deep_path.label = item.label or deep_path.label + mode = self:read_choice(item, deep_path) or mode + + if index == #items and type(chain[#chain]) == 'number' then + mode = enums.read_mode.quit + end + elseif item_type == enums.item.condition then + local result, chain_value + + if type(item.condition) == 'string' then + local success = self:check_condition(item.condition) + result = success and item.success or (item.failure or { }) + chain_value = success and 't' or 'f' + elseif type(item.condition) == 'table' then + local success = self:check_switch(item.condition) + result = success > 0 and item.success[success] or (item.failure or { }) + chain_value = success > 0 and ('t' .. success) or 'f' + end + + if type(result) == 'string' then + mode = enums.read_mode.text + mode = self:read_text({ text = result }, context) or mode + elseif type(result) == 'table' then + local deep_path = make_deep_path({ index, chain_value }) + mode = self:read_items(result, deep_path, depth + 2, mode) or mode + end + elseif item_type == enums.item.variable then + self:assign_value_to(item.var, item.value, item.temp) + end + + -- Read the label + if item.label ~= nil and item_type ~= enums.item.choice and not skip then + local label_path = lume.clone(path) + label_path.label = item.label + self:visit(label_path) + end + + if mode == enums.read_mode.quit then + break + end + end + + if depth == 0 then + for index = #self.paragraphs, 1, -1 do + local paragraph = self.paragraphs[index] + if (not paragraph.text or #paragraph.text == 0) and (not paragraph.tags or #paragraph.tags == 0) then + -- Remove safe prefixes and suffixes of failured inline conditions + table.remove(self.paragraphs, index) + else + -- Remove <> tail from unexpectedly broken paragraphs + paragraph.text = paragraph.text:match('(.-)%s*<>$') or paragraph.text + end + end + end + + return mode +end + +---@private +function story:read_text(item, context) + local text = item.text + local tags = type(item.tags) == 'string' and { item.tags } or item.tags + local paragraphs = #self.stack == 0 and self.paragraphs or self.stack[#self.stack] + + if text ~= nil or tags ~= nil then + local paragraph = { text = text or '<>', tags = tags } + local stack + + paragraph.text, stack = self:replace_expressions(paragraph.text) + paragraph.text = paragraph.text:gsub('%s+', ' ') + + table.insert(stack, paragraph) + + for _, paragraph in ipairs(stack) do + + local glued_by_prev = #paragraphs > 0 and paragraphs[#paragraphs].text:sub(-2) == '<>' + local glued_by_this = text ~= nil and text:sub(1, 2) == '<>' + + if glued_by_prev then + local prev_paragraph = paragraphs[#paragraphs] + prev_paragraph.text = prev_paragraph.text:sub(1, #prev_paragraph.text - 2) + paragraphs[#paragraphs] = prev_paragraph + end + + if glued_by_this then + paragraph.text = paragraph.text:sub(3) + end + + if glued_by_prev or (glued_by_this and #paragraphs > 0) then + local prev_paragraph = paragraphs[#paragraphs] + prev_paragraph.text = (prev_paragraph.text .. paragraph.text):gsub('%s+', ' ') + prev_paragraph.tags = lume.concat(prev_paragraph.tags, paragraph.tags) + prev_paragraph.tags = #prev_paragraph.tags > 0 and prev_paragraph.tags or nil + paragraphs[#paragraphs] = prev_paragraph + else + table.insert(paragraphs, #paragraphs + 1, paragraph) + end + end + end + + if item.divert ~= nil then + if item.divert.tunnel then + table.insert(self.tunnels, context) + end + + local mode = self:jump_path(item.divert.path) + + if item.divert.tunnel then + return (mode == enums.read_mode.quit and #self.choices == 0) and enums.read_mode.text or mode + end + + return enums.read_mode.quit + end + + if item.exit then + local context = assert(table.remove(self.tunnels), 'Tunnel stack is empty') + self.current_path = context.previous + if context.restore then + + if context.items == nil then + self:read_path(context.path) + return enums.read_mode.quit + end + + self:read_items(context.items, context.path, context.depth, context.mode, context.index) + return enums.read_mode.quit + end + + return enums.read_mode.text + end +end + +---@private +function story:read_alts(item, path, depth, mode) + assert(item.alts, 'Alternatives can\'t be nil') + local alts = lume.clone(item.alts) + + local sequence = item.sequence or enums.sequence.stopping + if type(sequence) == 'string' then + sequence = enums.sequence[item.sequence] + end + + self:visit(path) + local visits = self:get_visits_for_path(path) + local index = 0 + + if item.shuffle then + local seed_key = (path.knot or '_') .. '.' .. (path.stitch or '_') .. ':' .. path.label + local seed = visits % #alts == 1 and (self.debug_seed or os.time() * 1000) or self.seeds[seed_key] + self.seeds[seed_key] = seed + + for index, alt in ipairs(alts) do + math.randomseed(seed + index) + + local pair_index = index < #alts and math.random(index, #alts) or index + alts[index] = alts[pair_index] + alts[pair_index] = alt + end + end + + if sequence == enums.sequence.cycle then + index = visits % #alts + index = index > 0 and index or #alts + elseif sequence == enums.sequence.stopping then + index = visits < #alts and visits or #alts + elseif sequence == enums.sequence.once then + index = visits + end + + local alt = index <= #alts and alts[index] or { } + local items = type(alt) == 'string' and { alt } or alt + + return self:read_items(items, path, depth, mode) +end + +---@private +function random_seed() + +end + +---@private +function story:read_choice(item, path) + local is_fallback = item.choice == 0 + + if is_fallback then + -- Works correctly only when a fallback is the last choice + if #self.choices == 0 then + if item.divert ~= nil then + self:jump_path(item.divert.path) + else + self:read_path(path) + end + end + + return enums.read_mode.quit + end + + local title = self:replace_expressions(item.choice) + title = title:match('(.-)%s*<>$') or title + + local choice = { + title = title, + text = item.text ~= nil and self:replace_expressions(item.text) or title, + divert = item.divert, + tags = item.tags, + path = path + } + + if item.sticky or self:get_visits_for_path(path) == 0 then + table.insert(self.choices, #self.choices + 1, choice) + end +end + +-- Expressions + +---@private +function story:replace_expressions(text) + local stack = { } + + local replaced = text:gsub('%b##', function(match) + if #match == 2 then + return '#' + else + local result + result, stack = self:do_expression(match:sub(2, #match - 1)) + + if type(result) == 'table' then + result = self.list_mt.__tostring(result) + elseif type(result) == 'boolean' then + result = result and 1 or 0 + elseif type(result) == 'number' then + result = tostring(result) + + if result:sub(-2) == '.0' then + result = result:sub(1, -3) + end + elseif result == nil then + result = '' + end + + return result + end + end) + + return replaced, stack +end + +---@private +function story:check_switch(conditions) + for index, condition in ipairs(conditions) do + if self:check_condition(condition) then + return index + end + end + + return 0 +end + +---@private +function story:check_condition(condition) + if condition == nil then + return true + end + + local result, stack = self:do_expression(condition) + + for _, paragraph in ipairs(stack) do + table.insert(self.paragraphs, paragraph) + end + + if type(result) == 'table' and not next(result) then + result = nil + end + + return result ~= nil and result ~= false +end + +---@private +function story:do_expression(expression) + assert(type(expression) == 'string', 'Expression must be a string') + + local code = '' + local lists = { } + local stack = { } + + -- Replace operators + expression = expression:gsub('!=', '~=') + expression = expression:gsub('%s*||%s*', ' or ') + expression = expression:gsub('%s*%&%&%s*', ' and ') + expression = expression:gsub('%s+has%s+', ' ? ') + expression = expression:gsub('%s+hasnt%s+', ' !? ') + expression = expression:gsub('!%s*%w', ' not ') + + -- Replace functions results + expression = expression:gsub('[%a_][%w_]*%b()', function(match) + local func_name = match:match('([%a_][%w_]*)%(') + local params_string = match:match('[%a_][%w_]*%((.+)%)') + local params = params_string ~= nil and lume.map(lume.split(params_string, ','), lume.trim) or nil + + for index, param in ipairs(params or { }) do + params[index] = self:do_expression(param) + end + + local func = self.functions[func_name] + + if func ~= nil then + local value = func((table.unpack or unpack)(params or { })) + + if type(value) == 'table' then + lists[#lists + 1] = value + return '__list' .. #lists + else + return lume.serialize(value) + end + elseif self.lists[func_name] ~= nil then + local index = params and params[1] or 0 + local item = self.lists[func_name][index] + local list = item and { [func_name] = { [item] = true } } or { } + + lists[#lists + 1] = list + + return '__list' .. #lists + else + self.return_value = nil + + local func_params = { } + local path = self.current_path + + if params then + for i, value in ipairs(params) do + func_params[self.params[func_name][i]] = tostring(value) + end + end + + table.insert(self.stack, { }) + self:jump_path(func_name, func_params) + self.current_path = path + + for _, paragraph in ipairs(table.remove(self.stack)) do + table.insert(stack, paragraph) + end + + return self.return_value + end + end) + + -- Replace lists + expression = expression:gsub('%(([%s%w%.,_]*)%)', function(match) + local list = self:make_list_for(match) + + if list ~= nil then + lists[#lists + 1] = list + return '__list' .. #lists + else + return 'nil' + end + end) + + -- Store strings to the bag before to replace variables + -- otherwise it can replace strings inside quotes to nils. + -- Info: Ink doesn't interpret single quotes '' as string expression value + local strings_bag = { } + expression = expression:gsub('%b\"\"', function(match) + table.insert(strings_bag, match) + return '#' .. #strings_bag .. '#' + end) + + -- Replace variables + expression = expression:gsub('[%a_][%w_%.]*', function(match) + local exceptions = { 'and', 'or', 'true', 'false', 'nil', 'not'} + + if lume.find(exceptions, match) or match:match('__list%d*') then + return match + else + local value = self:get_value_for(match) + + if type(value) == 'table' then + lists[#lists + 1] = value + return '__list' .. #lists + else + return lume.serialize(value) + end + end + end) + + -- Replace with math results + expression = expression:gsub('[%a_#][%w_%.#]*[%s]*[%?!]+[%s]*[%a_#][%w_%.#]*', function(match) + local lhs, operator, rhs = match:match('([%a_#][%w_%.#]*)[%s]*([%!?]+)[%s]*([%a_#][%w_%.#]*)') + + if lhs:match('__list%d*') then + return lhs .. ' % ' .. rhs .. (operator == '?' and ' == true' or ' == false') + else + return 'string.match(' .. lhs .. ', ' .. rhs .. ')' .. (operator == '?' and ' ~= nil' or ' == nil') + end + end) + + -- Restore strings after variables replacement + expression = expression:gsub('%b##', function(match) + local index = tonumber(match:sub(2, -2)) + return strings_bag[index or 0] + end) + + -- Attach the metatable to list tables + if #lists > 0 then + code = code .. 'local mt = require(\'narrator.list.mt\')\n' + code = code .. 'mt.lists = ' .. lume.serialize(self.lists) .. '\n\n' + + for index, list in pairs(lists) do + local name = '__list' .. index + + code = code .. 'local ' .. name .. ' = ' .. lume.serialize(list) .. '\n' + code = code .. 'setmetatable(' .. name .. ', mt)\n\n' + end + end + + code = code .. 'return ' .. expression + return lume.dostring(code), stack +end + + +-- Variables + +---@private +function story:assign_value_to(variable, expression, temp) + if self.constants[variable] ~= nil then + return + end + local value = self:do_expression(expression) + + if #variable == 0 then + return + end + local storage = (temp or self.temp[variable] ~= nil) and self.temp or self.variables + + if storage[variable] == value then + return + end + storage[variable] = value + + local observer = self.observers[variable] + if observer ~= nil then + observer(value) + end +end + +---@private +function story:get_value_for(variable) + local result = self.temp[variable] + + if result == nil then + result = self.variables[variable] + end + if result == nil then + result = self.constants[variable] + end + if result == nil then + result = self:make_list_for(variable) + end + if result == nil then + local visits = self:get_visits_with_context(variable, self.current_path) + result = visits > 0 and visits or nil + end + + return result +end + + +-- Lists + +---@private +function story:make_list_for(expression) + local result = { } + if not expression:find('%S') then + return result + end + + local items = lume.array(expression:gmatch('[%w_%.]+')) + + for _, item in ipairs(items) do + local list_name, item_name = self:get_list_name_for(item) + if list_name ~= nil and item_name ~= nil then + result[list_name] = result[list_name] or { } + result[list_name][item_name] = true + end + end + + return next(result) ~= nil and result or nil +end + +---@private +function story:get_list_name_for(name) + local list_name, item_name = name:match('([%w_]+)%.([%w_]+)') + item_name = item_name or name + + if list_name == nil then + for key, list in pairs(self.lists) do + for _, string in ipairs(list) do + if string == item_name then + list_name = key + break + end + end + end + end + + local not_found = list_name == nil or self.lists[list_name] == nil + + if not_found then + return nil + end + + return list_name, item_name +end + + +-- Visits + +---@private +function story:visit(path) + local path_is_changed = self.current_path == nil or path.knot ~= self.current_path.knot or path.stitch ~= self.current_path.stitch + + if path_is_changed then + if self.current_path == nil or path.knot ~= self.current_path.knot then + local knot = path.knot or '_' + local visits = self.visits[knot] or { _root = 0 } + + visits._root = visits._root + 1 + self.visits[knot] = visits + end + + local knot, stitch = path.knot or '_', path.stitch or '_' + local visits = self.visits[knot][stitch] or { _root = 0 } + + visits._root = visits._root + 1 + self.visits[knot][stitch] = visits + end + + if path.label ~= nil then + local knot, stitch, label = path.knot or '_', path.stitch or '_', path.label + self.visits[knot] = self.visits[knot] or { _root = 1, _ = { _root = 1 } } + self.visits[knot][stitch] = self.visits[knot][stitch] or { _root = 1 } + + local visits = self.visits[knot][stitch][label] or 0 + visits = visits + 1 + self.visits[knot][stitch][path.label] = visits + end + + self.current_path = lume.clone(path) + self.current_path.label = nil + self.temp = path_is_changed and { } or self.temp +end + +---@private +function story:get_visits_for_path(path) + if path == nil then + return 0 + end + + local knot, stitch, label = path.knot or '_', path.stitch, path.label + + if stitch == nil and label ~= nil then + stitch = '_' + end + + local knot_visits = self.visits[knot] + + if knot_visits == nil then + return 0 + elseif stitch == nil then + return knot_visits._root or 0 + end + + local stitch_visits = knot_visits[stitch] + + if stitch_visits == nil then + return 0 + elseif label == nil then + return stitch_visits._root or 0 + end + + local label_visits = stitch_visits[label] + return label_visits or 0 +end + +---@private +function story:get_visits_with_context(path_string, context) + local path = self:path_from_string(path_string, context) + local visits_count = self:get_visits_for_path(path) + return visits_count +end + +---@private +function story:path_from_string(path_string, context) + local path_string = path_string or '' + local context_knot = context and context.knot + local context_stitch = context and context.stitch + + context_knot = context_knot or '_' + context_stitch = context_stitch or '_' + + -- Try to parse 'part1.part2.part3' + local part1, part2, part3 = path_string:match('([%w_]+)%.([%w_]+)%.([%w_]+)') + + if not part1 then + -- Try to parse 'part1.part2' + part1, part2 = path_string:match('([%w_]+)%.([%w_]+)') + end + + if not part1 then + -- Try to parse 'part1' + part1 = #path_string > 0 and path_string or nil + end + + local path = { } + + if not part1 then + -- Path is empty + return path + end + + if part3 then + -- Path is 'part1.part2.part3' + path.knot = part1 + path.stitch = part2 + path.label = part3 + + return path + end + + if part2 then + -- Path is 'part1.part2' + + if self.tree[part1] and self.tree[part1][part2] then + -- Knot 'part1' and stitch 'part2' exist so return part1.part2 + path.knot = part1 + path.stitch = part2 + + return path + end + + if self.tree[context_knot][part1] then + -- Stitch 'part1' exists so return context_knot.part1.part2 + path.knot = context_knot + path.stitch = part1 + path.label = part2 + + return path + end + + if self.tree[part1] then + -- Knot 'part1' exists so seems it's a label with a root stitch + path.knot = part1 + path.stitch = '_' + path.label = part2 + + return path + end + + if self.tree._[part1] then + -- Root stitch 'part1' exists so return _.part1.part2 + path.knot = '_' + path.stitch = part1 + path.label = part2 + + return path + end + end + + if part1 then + -- Path is 'part1' + if self.tree[context_knot][part1] then + -- Stitch 'part1' exists so return context_knot.part1 + path.knot = context_knot + path.stitch = part1 + + return path + elseif self.tree[part1] then + -- Knot 'part1' exists so return part1 + path.knot = part1 + + return path + else + -- Seems it's a label + path.knot = context_knot + path.stitch = context_stitch + path.label = part1 + end + end + + return path +end + + +-- Ink functions + +---@private +function story:ink_functions() + return { + CHOICE_COUNT = function() return #self.choices end, + SEED_RANDOM = function(seed) self.debug_seed = seed end, + POW = function(x, y) return math.pow and math.pow(x, y) or x ^ y end, + + RANDOM = function(x, y) + math.randomseed(self.debug_seed or os.time() * 1000) + return math.random(x, y) + end, + + INT = function(x) return math.floor(x) end, + FLOOR = function(x) return math.floor(x) end, + FLOAT = function(x) return x end, + + -- TURNS = function() return nil end -- TODO + -- TURNS_SINCE = function(path) return nil end -- TODO + + LIST_VALUE = function(list) return self.list_mt.first_raw_value_of(list) end, + LIST_COUNT = function(list) return self.list_mt.__len(list) end, + LIST_MIN = function(list) return self.list_mt.min_value_of(list) end, + LIST_MAX = function(list) return self.list_mt.max_value_of(list) end, + + LIST_RANDOM = function(list) + math.randomseed(self.debug_seed or os.time() * 1000) + return self.list_mt.random_value_of(list) + end, + + LIST_ALL = function(list) return self.list_mt.posible_values_of(list) end, + LIST_RANGE = function(list, min, max) return self.list_mt.range_of(list, min, max) end, + LIST_INVERT = function(list) return self.list_mt.invert(list) end + } +end + +return story \ No newline at end of file diff --git a/libs/narrator/stories/debug.ink b/libs/narrator/stories/debug.ink new file mode 100644 index 0000000..e69de29 diff --git a/libs/narrator/stories/game.ink b/libs/narrator/stories/game.ink new file mode 100644 index 0000000..7f05169 --- /dev/null +++ b/libs/narrator/stories/game.ink @@ -0,0 +1,19 @@ +- I looked at Monsieur Fogg +* ... and I could contain myself no longer. + 'What is the purpose of our journey, Monsieur?' + 'A wager,' he replied. + * * 'A wager!'[] I returned. + He nodded. + * * * 'But surely that is foolishness!' + * * * 'A most serious matter then!' + - - - He nodded again. + * * * 'But can we win?' + 'That is what we will endeavour to find out,' he answered. + * * * 'A modest wager, I trust?' + 'Twenty thousand pounds,' he replied, quite flatly. + * * * I asked nothing further of him then[.], and after a final, polite cough, he offered nothing more to me. <> + * * 'Ah[.'],' I replied, uncertain what I thought. + - - After that, <> +* ... but I said nothing[] and <> +- we passed the day in silence. +- -> END \ No newline at end of file diff --git a/libs/narrator/stories/game.lua b/libs/narrator/stories/game.lua new file mode 100644 index 0000000..37771f9 --- /dev/null +++ b/libs/narrator/stories/game.lua @@ -0,0 +1 @@ +return {inclusions={},constants={},version={tree=1,engine=1},tree={_={_={"I looked at Monsieur Fogg",{text="... and I could contain myself no longer.",choice="... and I could contain myself no longer.",node={"'What is the purpose of our journey, Monsieur?'","'A wager,' he replied.",{text="'A wager!' I returned.",choice="'A wager!'",node={"He nodded.",{text="'But surely that is foolishness!'",choice="'But surely that is foolishness!'"},{text="'A most serious matter then!'",choice="'A most serious matter then!'"},"He nodded again.",{text="'But can we win?'",choice="'But can we win?'",node={"'That is what we will endeavour to find out,' he answered."}},{text="'A modest wager, I trust?'",choice="'A modest wager, I trust?'",node={"'Twenty thousand pounds,' he replied, quite flatly."}},{text="I asked nothing further of him then, and after a final, polite cough, he offered nothing more to me. <>",choice="I asked nothing further of him then."}}},{text="'Ah,' I replied, uncertain what I thought.",choice="'Ah.'"},"After that, <>"}},{text="... but I said nothing and <>",choice="... but I said nothing"},"we passed the day in silence.",{divert="END"}}}},lists={},variables={}} \ No newline at end of file diff --git a/libs/narrator/test/cases.lua b/libs/narrator/test/cases.lua new file mode 100644 index 0000000..173fc83 --- /dev/null +++ b/libs/narrator/test/cases.lua @@ -0,0 +1,78 @@ +-- +-- Test cases + +local runtime = { + 'continue', + 'observing', + 'binding', + 'set-get', + 'visits', + 'tags', + 'jumping', + 'save-load' +} + +local units = { + 'inclusions', + 'comments', + 'knots', + 'stitches', + + 'text-line', + 'text-lines', + 'text-tags', + 'text-glue', + + 'choices-basic', + 'choices-tags', + 'choices-conditional', + 'choices-sticky', + 'choices-fallback', + 'choices-tunnel', + + 'labels-choices', + 'labels-nested', + + 'branching', + 'nesting', + 'gather', + 'loop', + 'vars', + 'constants', + 'expressions', + 'queries', + + 'conditions-inline', + 'alts-inline', + 'conditions-switch', + 'alts-blocks', + + 'lists-basic', + 'lists-operators', + 'lists-queries', + + 'tunnels', + 'escape', + 'functions' +} + +local stories = { + -- No complex stories at the moment +} + +local cases = { + runtime = runtime, + units = units, + stories = stories +} + +local folder_separator = package.config:sub(1, 1) +for folder_name, folder_cases in pairs(cases) do + local items_with_foldes = { } + for _, case in ipairs(folder_cases) do + table.insert(items_with_foldes, folder_name .. folder_separator .. case) + end + cases[folder_name] = items_with_foldes +end + +return cases \ No newline at end of file diff --git a/libs/narrator/test/run.lua b/libs/narrator/test/run.lua new file mode 100644 index 0000000..c008592 --- /dev/null +++ b/libs/narrator/test/run.lua @@ -0,0 +1,252 @@ +-- +-- Testing with Busted library + +-- +-- Dependencies + +local import = require +import('busted.runner')() + +if os.getenv('LOCAL_LUA_DEBUGGER_VSCODE') == '1' then + import('lldebugger').start() +end + +local bot = require('bot') +local narrator = require('narrator.narrator') +local lume = require('narrator.libs.lume') + +-- +-- Constants + +local folder_separator = package.config:sub(1, 1) +local tests_folder = 'test' .. folder_separator + +--- Make path for a .lua file +-- @param case string: an runtime test case +-- @return string: a .lua path +local function lua_path(case) + return tests_folder .. case .. '.lua' +end + +--- Make path for an .ink file +-- @param case string: an Ink test case +-- @return string: an .ink path +local function ink_path(case) + return tests_folder .. case .. '.ink' +end + +--- Make path for a .txt file +-- @param case string: an Ink test case +-- @param answers table: a sequence of answers (numbers) +-- @return string: a .txt path +local function txt_path(case, answers) + local path = tests_folder .. case + if answers and #answers > 0 then + path = path .. folder_separator .. table.concat(answers, '-') + end + path = path .. '.txt' + return path +end + +--- Get all possible sequences and logs of the case +-- @param case string: an Ink test case +-- @return table: an array of possible games { sequence, log } +local function get_possible_results(case) + local path = ink_path(case) + local book = narrator.parse_file(path) + + local results = { } + local sequences = { { } } + local seq_index + + local function instructor(choices, step) + local cur_seq = sequences[seq_index] + local answer = cur_seq[step] + + if not answer then + -- Transform a current sequence to branches for each available choice + table.remove(sequences, seq_index) + + for index, _ in ipairs(choices) do + local new_seq = lume.concat(cur_seq, { index }) + table.insert(sequences, new_seq) + end + + -- Set a stop signal for the bot + answer = -1 + end + + return answer + end + + while #sequences > 0 do + -- Iterate sequences and play them + for index = 1, #sequences do + local sequence = sequences[index] + seq_index = index + + -- Play the sequence + local story = narrator.init_story(book) + local log = bot.play(story, instructor) + + -- If the sequence was finished then save the result and mark it as finished + if log then + local result = { sequence = sequence, log = log } + table.insert(results, result) + sequences[index] = { is_finished = true } + end + end + + -- Remove finished sequences + for index = #sequences, 1, -1 do + local sequence = sequences[index] + if sequence.is_finished then + table.remove(sequences, index) + end + end + end + + return results +end + +--- Create possible results for an Ink test case and save them to txt files +-- @param case string: an Ink test case +-- @param override boolean: override a txt file if it already exists. +local function create_txt_for_ink_case(case, override) + local override = override ~= nil and override or false + local results = get_possible_results(case) + + for _, result in ipairs(results) do + local txt_path = txt_path(case, #results > 1 and result.sequence or nil) + local file = io.open(txt_path, 'r') + local is_file_exists = file ~= nil + if is_file_exists then io.close(file) end + + if not is_file_exists or override then + local folder_path = txt_path:match('(.*' .. folder_separator .. ')') + local folder = io.open(folder_path, 'r') + local is_folder_exists = folder ~= nil + if is_folder_exists then + io.close(folder) + else + os.execute('mkdir ' .. folder_path) + end + + file = io.open(txt_path, 'w') + assert(file, 'Has no access to the file at path \'' .. txt_path .. '\'.') + + file:write(result.log) + file:close() + end + end +end + +--- Create possible results for Ink test cases and save them to txt files +-- @param cases table: an array of Ink test cases +-- @param override bool: override the txt file if it already exists +local function create_txt_for_ink_cases(cases, override) + for _, case in ipairs(cases) do + create_txt_for_ink_case(case, override) + end +end + +--- Test an Ink case +-- @param case string: an Ink test case +local function test_ink_case(case) + describe('Test an Ink case \'' .. case .. '\'.', function() + local path = ink_path(case) + local book = narrator.parse_file(path) + + local results = get_possible_results(case) + for _, result in ipairs(results) do + describe('Sequence is [' .. table.concat(result.sequence, '-') .. '].', function() + local txt_path = txt_path(case, #results > 1 and result.sequence or nil) + local file = io.open(txt_path, 'r') + + it('Checking results.', function() + assert.is_not_nil(file) + + local expected = file:read('*all') + file:close() + + assert.are.same(expected, result.log) + end) + end) + end + end) +end + +--- Test Ink cases +-- @param cases table: an array of test cases +local function test_ink_cases(cases) + for _, case in ipairs(cases) do + test_ink_case(case) + end +end + +--- Test a runtime case +-- @param case string: a runtime test case +local function test_lua_case(case) + describe('Test a runtime case \'' .. case .. '\'.', function() + local lua_path = lua_path(case) + loadfile(lua_path)(narrator, describe, it, assert) + end) +end + +--- Test runtime cases +-- @param cases table: an array of runtime test cases +local function test_lua_cases(cases) + for _, case in ipairs(cases) do + test_lua_case(case) + end +end + +--- Override math.random functions to prevent different results beetween machines and pass test-cases. +local function override_random() + local test_seed + local original_random = math.random + + math.randomseed = function(x) + test_seed = x + end + + math.random = function(x, y) + if test_seed then + local result = math.max(x, math.min(test_seed, y)) + test_seed = nil + return result + else + return original_random(x, y) + end + end +end + +-- +-- Main + +local case = nil +local cases = require('test.cases') +local override_case_results = false + +override_random() + +if override_case_results then + if case then + create_txt_for_ink_case(case, true) + else + create_txt_for_ink_cases(cases.units, true) + create_txt_for_ink_cases(cases.stories, true) + end +end + +if case then + if case:find('runtime/', 1, 8) then + test_lua_case(case) + else + test_ink_case(case) + end +else + test_lua_cases(cases.runtime) + test_ink_cases(cases.units) + test_ink_cases(cases.stories) +end \ No newline at end of file diff --git a/libs/narrator/test/runtime/binding.lua b/libs/narrator/test/runtime/binding.lua new file mode 100644 index 0000000..3681550 --- /dev/null +++ b/libs/narrator/test/runtime/binding.lua @@ -0,0 +1,42 @@ +local narrator, describe, it, assert = ... + +local content = [[ + ~ beep() + { sum(1, 2) } + { did_solve_puzzle("labirint") } +]] + +local book = narrator.parse_content(content) +local story = narrator.init_story(book) + +local is_beeped = false +local puzzles = { } + +story:bind('beep', function() + is_beeped = true +end) + +story:bind('sum', function(x, y) + return x + y +end) + +story:bind('did_solve_puzzle', function(puzzle) + puzzles[puzzle] = true +end) + +story:begin() + +it('Was a beep?', function() + assert.is_true(is_beeped) +end) + +it('Sum is equal to 3.', function() + local paragraphs = story:continue() + assert.equal(#paragraphs, 1) + assert.equal('3', paragraphs[1].text) +end) + +it('Labirint is sovled.', function() + local puzzle_is_solved = puzzles['labirint'] + assert.is_true(puzzle_is_solved) +end) \ No newline at end of file diff --git a/libs/narrator/test/runtime/continue.lua b/libs/narrator/test/runtime/continue.lua new file mode 100644 index 0000000..b54605e --- /dev/null +++ b/libs/narrator/test/runtime/continue.lua @@ -0,0 +1,30 @@ +local narrator, describe, it, assert = ... + +local content = [[ + Line 1 + Line 2 + Line 3 + Line 4 + Line 5 + Line 6 +]] + +local book = narrator.parse_content(content) +local story = narrator.init_story(book) + +story:begin() + +it('Get one paragraph.', function() + local paragraph = story:continue(1) + assert.equal(paragraph.text, 'Line 1') +end) + +it('Get two paragraphs.', function() + local paragraphs = story:continue(2) + assert.equal(#paragraphs, 2) +end) + +it('Get remain paragraphs.', function() + local paragraphs = story:continue() + assert.equal(#paragraphs, 3) +end) \ No newline at end of file diff --git a/libs/narrator/test/runtime/jumping.lua b/libs/narrator/test/runtime/jumping.lua new file mode 100644 index 0000000..83425f1 --- /dev/null +++ b/libs/narrator/test/runtime/jumping.lua @@ -0,0 +1,39 @@ +local narrator, describe, it, assert = ... + +local content = [[ + A root line + === knot + A knot line + = stitch + A stitch line + === somewhere + - (label) A label line +]] + +local book = narrator.parse_content(content) +local story = narrator.init_story(book) + +story:begin() + +local paragraphs = story:continue() + +it('Jump to label.', function() + story:jump_to('somewhere.label') + local paragraphs = story:continue() + assert.equal(#paragraphs, 1) + assert.equal(paragraphs[1].text, 'A label line') +end) + +it('Jump to stitch.', function() + story:jump_to('knot.stitch') + local paragraphs = story:continue() + assert.equal(#paragraphs, 1) + assert.equal(paragraphs[1].text, 'A stitch line') +end) + +it('Jump to knot.', function() + story:jump_to('knot') + local paragraphs = story:continue() + assert.equal(#paragraphs, 1) + assert.equal(paragraphs[1].text, 'A knot line') +end) \ No newline at end of file diff --git a/libs/narrator/test/runtime/observing.lua b/libs/narrator/test/runtime/observing.lua new file mode 100644 index 0000000..d8a8c06 --- /dev/null +++ b/libs/narrator/test/runtime/observing.lua @@ -0,0 +1,21 @@ +local narrator, describe, it, assert = ... + +local content = [[ + VAR mood = "sadly" + ~ mood = "sunny" +]] + +local book = narrator.parse_content(content) +local story = narrator.init_story(book) + +local mood + +story:observe('mood', function(value) + mood = value +end) + +story:begin() + +it('Sunny mood.', function() + assert.equal(mood, 'sunny') +end) \ No newline at end of file diff --git a/libs/narrator/test/runtime/save-load.lua b/libs/narrator/test/runtime/save-load.lua new file mode 100644 index 0000000..6bf4d3f --- /dev/null +++ b/libs/narrator/test/runtime/save-load.lua @@ -0,0 +1,59 @@ +local narrator, describe, it, assert = ... + +local content = [[ + VAR x = 1 + * (choice) [Hello] + - (hello) Hello world! + -> knot + === knot + = stitch + ~ x = 2 + ~ temp y = 3 + A line 1 + A line 2 + A line 3 + * Just a road to hell -> END + * The best road to hell -> END +]] + +local book = narrator.parse_content(content) + +local saved_state + +it('Saving', function() + local story = narrator.init_story(book) + story:begin() + story:continue() + story:choose(1) + story:continue(2) + + saved_state = story:save_state() + + local expected_path = { knot = 'knot', stitch = 'stitch' } + assert.are.same(saved_state.path, expected_path) + assert.equal(saved_state.variables['x'], 2) + assert.equal(saved_state.temp['y'], 3) + assert.equal(saved_state.visits._._.hello, 1) + assert.equal(#saved_state.output, 2) + assert.equal(#saved_state.paragraphs, 2) + assert.equal(#saved_state.choices, 2) +end) + +it('Loading.', function() + local story = narrator.init_story(book) + story:begin() + story:load_state(saved_state) + + local expected_path = { knot = 'knot', stitch = 'stitch' } + assert.are.same(story.current_path, expected_path) + assert.equal(story.variables['x'], 2) + assert.equal(story.temp['y'], 3) + assert.equal(story:get_visits('hello'), 1) + assert.equal(#story.output, 2) + + local paragraphs = story:continue() + local choices = story:get_choices() + + assert.equal(#paragraphs, 2) + assert.equal(#choices, 2) +end) \ No newline at end of file diff --git a/libs/narrator/test/runtime/set-get.lua b/libs/narrator/test/runtime/set-get.lua new file mode 100644 index 0000000..7e5d178 --- /dev/null +++ b/libs/narrator/test/runtime/set-get.lua @@ -0,0 +1,30 @@ +local narrator, describe, it, assert = ... + +local content = [[ + VAR x = 1 + * [Change x to 1] + * [Change x to 2] + * [Change x to 3] + x = { x } +]] + +local book = narrator.parse_content(content) +local story = narrator.init_story(book) + +story:begin() + +it('The x is equal to 1.', function() + local x = story.variables['x'] + assert.equal(x, 1) +end) + +it('The x is changed to 3.', function() + local answer = 3 + story.variables['x'] = answer + story:choose(answer) + + local paragraphs = story:continue() + assert.equal(#paragraphs, 1) + assert.equal(paragraphs[1].text, 'x = 3') +end) + diff --git a/libs/narrator/test/runtime/tags.lua b/libs/narrator/test/runtime/tags.lua new file mode 100644 index 0000000..72d8e64 --- /dev/null +++ b/libs/narrator/test/runtime/tags.lua @@ -0,0 +1,51 @@ +local narrator, describe, it, assert = ... + +local content = [[ + # global tag 1 + # global tag 2 # global tag 3 + Root line -> knot + === knot === + # knot tag + A knot line 1 # line 1 tag + A knot line 2 # line 2 tag + = stitch + # stitch tag + A stitch line # line 3 tag +]] + +local book = narrator.parse_content(content) +local story = narrator.init_story(book) + +story:begin() + +local paragraphs = story:continue() + +it('Global tags.', function() + local expected = { 'global tag 1', 'global tag 2', 'global tag 3' } + assert.are.same(expected, story.global_tags) + + local global_tags = story:get_tags() + assert.are.same(expected, global_tags) +end) + +it('Knot tags.', function() + local expected = { 'knot tag' } + local knot_tags = story:get_tags('knot') + assert.are.same(expected, knot_tags) +end) + +it('Stitch tags.', function() + local expected = { 'stitch tag' } + local stitch_tags = story:get_tags('knot.stitch') + assert.are.same(expected, stitch_tags) +end) + +it('Paragraph tags.', function() + assert.equal(#paragraphs, 2) + + local expected = { 'global tag 1', 'global tag 2', 'global tag 3', 'knot tag', 'line 1 tag' } + assert.are.same(expected, paragraphs[1].tags) + + local expected = { 'line 2 tag' } + assert.are.same(expected, paragraphs[2].tags) +end) \ No newline at end of file diff --git a/libs/narrator/test/runtime/visits.lua b/libs/narrator/test/runtime/visits.lua new file mode 100644 index 0000000..4502406 --- /dev/null +++ b/libs/narrator/test/runtime/visits.lua @@ -0,0 +1,65 @@ +local narrator, describe, it, assert = ... + +local content = [[ + A root text + + Go to knot -> knot + === knot + + Go to stitch -> stitch + = stitch + + Go to label -> label + - (label) Some text + + (choice) Go to root -> _._ +]] + +local book = narrator.parse_content(content) +local story = narrator.init_story(book) + +story:begin() + +local function visits() + local visits = { + root = story:get_visits(''), + knot = story:get_visits('knot'), + stitch = story:get_visits('knot.stitch'), + label = story:get_visits('knot.stitch.label'), + choice = story:get_visits('knot.stitch.choice') + } + + return visits +end + +local places = { 'root', 'knot', 'stitch', 'label' } + +for cycle = 1, 3 do + describe('Visits with cycle ' .. cycle .. '.', function() + story:continue() + + for place_index = 1, #places do + local place = places[place_index] + + it('Visit the ' .. place .. '.', function() + story:continue() + + local place = place + local divert = story.choices[1].divert + + local expected_root = cycle + local expected_knot = place_index > 1 and cycle or cycle - 1 + local expected_stitch = place_index > 2 and cycle or cycle - 1 + local expected_label = place_index > 3 and cycle or cycle - 1 + local expected_choice = place_index > 4 and cycle or cycle - 1 + + local visits = visits() + + assert.equal(visits.root, expected_root) + assert.equal(visits.knot, expected_knot) + assert.equal(visits.stitch, expected_stitch) + assert.equal(visits.label, expected_label) + assert.equal(visits.choice, expected_choice) + + story:choose(1) + end) + end + + end) +end \ No newline at end of file diff --git a/libs/narrator/test/units/alts-blocks.ink b/libs/narrator/test/units/alts-blocks.ink new file mode 100644 index 0000000..77bc1eb --- /dev/null +++ b/libs/narrator/test/units/alts-blocks.ink @@ -0,0 +1,60 @@ +VAR counter = 1 +-> casino + += casino +=== { counter } +At the table, I drew a card. <> +{ stopping: + - + I entered the casino. + - Okay. <> + I entered the casino again. + - { counter }. <> + + Once more, I went inside. +} +~ counter++ +{ counter < 5 : -> casino | -> cycle } + += cycle +=== { counter } +{ cycle: + - I held my breath. + - I waited impatiently. + - I paused. +} +~ counter++ +{ counter < 9: -> cycle | -> once } + += once +=== { counter } +{ once: + - Would my luck hold? + - Could I win the hand? +} +~ counter++ +{ counter < 13 : -> once | -> shuffle } + += shuffle +=== { counter } +{ SEED_RANDOM(counter - 13) } +At the table, I drew a card. <> +{ shuffle: + - Ace of Hearts. + - King of Spades. + - 2 of Diamonds. + 'You lose this time!' crowed the croupier. +} +~ counter++ +{ counter < 16 : -> shuffle | -> nested } + += nested +=== { counter } +{ SEED_RANDOM(counter - 16) } +{ true: + { shuffle: + - Okay. Ace of Hearts again. + - Okay. Ace of Hearts?! + } +} +-> END \ No newline at end of file diff --git a/libs/narrator/test/units/alts-blocks.txt b/libs/narrator/test/units/alts-blocks.txt new file mode 100644 index 0000000..c068708 --- /dev/null +++ b/libs/narrator/test/units/alts-blocks.txt @@ -0,0 +1,31 @@ +=== 1 +At the table, I drew a card. I entered the casino. +=== 2 +At the table, I drew a card. Okay. I entered the casino again. +=== 3 +At the table, I drew a card. 3. Once more, I went inside. +=== 4 +At the table, I drew a card. 4. Once more, I went inside. +=== 5 +I held my breath. +=== 6 +I waited impatiently. +=== 7 +I paused. +=== 8 +I held my breath. +=== 9 +Would my luck hold? +=== 10 +Could I win the hand? +=== 11 +=== 12 +=== 13 +At the table, I drew a card. Ace of Hearts. +=== 14 +At the table, I drew a card. King of Spades. +=== 15 +At the table, I drew a card. 2 of Diamonds. +'You lose this time!' crowed the croupier. +=== 16 +Okay. Ace of Hearts again. \ No newline at end of file diff --git a/libs/narrator/test/units/alts-inline.ink b/libs/narrator/test/units/alts-inline.ink new file mode 100644 index 0000000..6731f66 --- /dev/null +++ b/libs/narrator/test/units/alts-inline.ink @@ -0,0 +1,37 @@ +VAR counter = 0 +-> coffee + += coffee +{I bought a coffee with my five-pound note.|I bought a second coffee for my friend.|I didn't have enough money to buy any more coffee.} +~ counter++ +{ counter < 4 : -> coffee | -> today } + += today +It was {&Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday} today. +~ counter++ +{ counter < 15 : -> today | -> joke} + += joke +He told me a joke. {!I laughed politely.|I smiled.|I grimaced.|I promised myself to not react again.} +~ counter++ +{ counter < 20 : -> joke | -> coin} + += coin +{ SEED_RANDOM(20 - counter) } +I tossed the coin. {~Heads|Tails}. +~ counter++ +{ counter < 22 : -> coin | -> lights} + += lights +I took a step forward. {!||Then the lights went out. -> dark } +~ counter++ +{ counter < 27 : -> lights | -> ratbear} + += dark +So dark... +-> ratbear + += ratbear +The Ratbear {&{wastes no time and |}swipes|scratches } {&at you|into your {&leg|arm|cheek}}. +~ counter++ +{ counter < 32 : -> ratbear | -> END } \ No newline at end of file diff --git a/libs/narrator/test/units/alts-inline.txt b/libs/narrator/test/units/alts-inline.txt new file mode 100644 index 0000000..c1aa2f3 --- /dev/null +++ b/libs/narrator/test/units/alts-inline.txt @@ -0,0 +1,33 @@ +I bought a coffee with my five-pound note. +I bought a second coffee for my friend. +I didn't have enough money to buy any more coffee. +I didn't have enough money to buy any more coffee. +It was Monday today. +It was Tuesday today. +It was Wednesday today. +It was Thursday today. +It was Friday today. +It was Saturday today. +It was Sunday today. +It was Monday today. +It was Tuesday today. +It was Wednesday today. +It was Thursday today. +He told me a joke. I laughed politely. +He told me a joke. I smiled. +He told me a joke. I grimaced. +He told me a joke. I promised myself to not react again. +He told me a joke. +I tossed the coin. Heads. +I tossed the coin. Tails. +I took a step forward. +I took a step forward. +I took a step forward. Then the lights went out. So dark... +The Ratbear wastes no time and swipes at you. +The Ratbear scratches into your leg. +The Ratbear swipes at you. +The Ratbear scratches into your arm. +The Ratbear swipes at you. +The Ratbear scratches into your cheek. +The Ratbear swipes at you. +The Ratbear scratches into your leg. \ No newline at end of file diff --git a/libs/narrator/test/units/branching.ink b/libs/narrator/test/units/branching.ink new file mode 100644 index 0000000..e0da245 --- /dev/null +++ b/libs/narrator/test/units/branching.ink @@ -0,0 +1,21 @@ +-> back_in_london + +=== back_in_london === + +We arrived into London at 9.45pm exactly. +* 'There is not a moment to lose!'[] I declared. + -> hurry_outside +* 'Monsieur, let us savour this moment!'[] I declared. + My master clouted me firmly around the head and dragged me out of the door. + -> dragged_outside +* [We hurried home] -> hurry_outside + +=== hurry_outside === +We hurried home to Savile Row -> as_fast_as_we_could + +=== dragged_outside === +He insisted that we hurried home to Savile Row +-> as_fast_as_we_could + +=== as_fast_as_we_could === +<> as fast as we could. \ No newline at end of file diff --git a/libs/narrator/test/units/branching/1.txt b/libs/narrator/test/units/branching/1.txt new file mode 100644 index 0000000..aeef718 --- /dev/null +++ b/libs/narrator/test/units/branching/1.txt @@ -0,0 +1,8 @@ +We arrived into London at 9.45pm exactly. + +>) 'There is not a moment to lose!' +2) 'Monsieur, let us savour this moment!' +3) We hurried home + +'There is not a moment to lose!' I declared. +We hurried home to Savile Row as fast as we could. \ No newline at end of file diff --git a/libs/narrator/test/units/branching/2.txt b/libs/narrator/test/units/branching/2.txt new file mode 100644 index 0000000..06ccb86 --- /dev/null +++ b/libs/narrator/test/units/branching/2.txt @@ -0,0 +1,9 @@ +We arrived into London at 9.45pm exactly. + +1) 'There is not a moment to lose!' +>) 'Monsieur, let us savour this moment!' +3) We hurried home + +'Monsieur, let us savour this moment!' I declared. +My master clouted me firmly around the head and dragged me out of the door. +He insisted that we hurried home to Savile Row as fast as we could. \ No newline at end of file diff --git a/libs/narrator/test/units/branching/3.txt b/libs/narrator/test/units/branching/3.txt new file mode 100644 index 0000000..c6ef2af --- /dev/null +++ b/libs/narrator/test/units/branching/3.txt @@ -0,0 +1,7 @@ +We arrived into London at 9.45pm exactly. + +1) 'There is not a moment to lose!' +2) 'Monsieur, let us savour this moment!' +>) We hurried home + +We hurried home to Savile Row as fast as we could. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-basic.ink b/libs/narrator/test/units/choices-basic.ink new file mode 100644 index 0000000..dd0fc6d --- /dev/null +++ b/libs/narrator/test/units/choices-basic.ink @@ -0,0 +1,9 @@ +"What's that?" my master asked again. +* I don't know. + Okay... +* [Eat answer!] + Nice to hear from you! +* "I am somewhat tired[."]," I repeated. + "Really," he responded. "How deleterious." +* "Nothing, Monsieur!"[] I replied. + "Very good, then." \ No newline at end of file diff --git a/libs/narrator/test/units/choices-basic/1.txt b/libs/narrator/test/units/choices-basic/1.txt new file mode 100644 index 0000000..23263f5 --- /dev/null +++ b/libs/narrator/test/units/choices-basic/1.txt @@ -0,0 +1,9 @@ +"What's that?" my master asked again. + +>) I don't know. +2) Eat answer! +3) "I am somewhat tired." +4) "Nothing, Monsieur!" + +I don't know. +Okay... \ No newline at end of file diff --git a/libs/narrator/test/units/choices-basic/2.txt b/libs/narrator/test/units/choices-basic/2.txt new file mode 100644 index 0000000..a563766 --- /dev/null +++ b/libs/narrator/test/units/choices-basic/2.txt @@ -0,0 +1,8 @@ +"What's that?" my master asked again. + +1) I don't know. +>) Eat answer! +3) "I am somewhat tired." +4) "Nothing, Monsieur!" + +Nice to hear from you! \ No newline at end of file diff --git a/libs/narrator/test/units/choices-basic/3.txt b/libs/narrator/test/units/choices-basic/3.txt new file mode 100644 index 0000000..40f1977 --- /dev/null +++ b/libs/narrator/test/units/choices-basic/3.txt @@ -0,0 +1,9 @@ +"What's that?" my master asked again. + +1) I don't know. +2) Eat answer! +>) "I am somewhat tired." +4) "Nothing, Monsieur!" + +"I am somewhat tired," I repeated. +"Really," he responded. "How deleterious." \ No newline at end of file diff --git a/libs/narrator/test/units/choices-basic/4.txt b/libs/narrator/test/units/choices-basic/4.txt new file mode 100644 index 0000000..4dcf8f1 --- /dev/null +++ b/libs/narrator/test/units/choices-basic/4.txt @@ -0,0 +1,9 @@ +"What's that?" my master asked again. + +1) I don't know. +2) Eat answer! +3) "I am somewhat tired." +>) "Nothing, Monsieur!" + +"Nothing, Monsieur!" I replied. +"Very good, then." \ No newline at end of file diff --git a/libs/narrator/test/units/choices-conditional.ink b/libs/narrator/test/units/choices-conditional.ink new file mode 100644 index 0000000..1098e39 --- /dev/null +++ b/libs/narrator/test/units/choices-conditional.ink @@ -0,0 +1,17 @@ +-> door + +=== door === +* { not france.paris } [Go to Paris] -> france.paris +* { not england.london } [Go to London] -> england.london +* { france || england } [Go home] + Sweet home... -> END + +=== france === += paris +Hello Paris! +-> door + +=== england === += london +Hello London! +-> door \ No newline at end of file diff --git a/libs/narrator/test/units/choices-conditional/1-1-1.txt b/libs/narrator/test/units/choices-conditional/1-1-1.txt new file mode 100644 index 0000000..019db73 --- /dev/null +++ b/libs/narrator/test/units/choices-conditional/1-1-1.txt @@ -0,0 +1,14 @@ + +>) Go to Paris +2) Go to London + +Hello Paris! + +>) Go to London +2) Go home + +Hello London! + +>) Go home + +Sweet home... \ No newline at end of file diff --git a/libs/narrator/test/units/choices-conditional/1-2.txt b/libs/narrator/test/units/choices-conditional/1-2.txt new file mode 100644 index 0000000..edf7b50 --- /dev/null +++ b/libs/narrator/test/units/choices-conditional/1-2.txt @@ -0,0 +1,10 @@ + +>) Go to Paris +2) Go to London + +Hello Paris! + +1) Go to London +>) Go home + +Sweet home... \ No newline at end of file diff --git a/libs/narrator/test/units/choices-conditional/2-1-1.txt b/libs/narrator/test/units/choices-conditional/2-1-1.txt new file mode 100644 index 0000000..bcae982 --- /dev/null +++ b/libs/narrator/test/units/choices-conditional/2-1-1.txt @@ -0,0 +1,14 @@ + +1) Go to Paris +>) Go to London + +Hello London! + +>) Go to Paris +2) Go home + +Hello Paris! + +>) Go home + +Sweet home... \ No newline at end of file diff --git a/libs/narrator/test/units/choices-conditional/2-2.txt b/libs/narrator/test/units/choices-conditional/2-2.txt new file mode 100644 index 0000000..712350a --- /dev/null +++ b/libs/narrator/test/units/choices-conditional/2-2.txt @@ -0,0 +1,10 @@ + +1) Go to Paris +>) Go to London + +Hello London! + +1) Go to Paris +>) Go home + +Sweet home... \ No newline at end of file diff --git a/libs/narrator/test/units/choices-fallback.ink b/libs/narrator/test/units/choices-fallback.ink new file mode 100644 index 0000000..6f4f9f4 --- /dev/null +++ b/libs/narrator/test/units/choices-fallback.ink @@ -0,0 +1,10 @@ +-> find_help + +=== find_help === + + You search desperately for a friendly face in the crowd. + * The woman in the hat[?] pushes you roughly aside. -> find_help + * The man with the briefcase[?] looks disgusted as you stumble past him. -> find_help + * -> + But it is too late: you collapse onto the station platform. This is the end. + -> END \ No newline at end of file diff --git a/libs/narrator/test/units/choices-fallback/1-1.txt b/libs/narrator/test/units/choices-fallback/1-1.txt new file mode 100644 index 0000000..fb391fa --- /dev/null +++ b/libs/narrator/test/units/choices-fallback/1-1.txt @@ -0,0 +1,11 @@ +You search desperately for a friendly face in the crowd. + +>) The woman in the hat? +2) The man with the briefcase? + +The woman in the hat pushes you roughly aside. You search desperately for a friendly face in the crowd. + +>) The man with the briefcase? + +The man with the briefcase looks disgusted as you stumble past him. You search desperately for a friendly face in the crowd. +But it is too late: you collapse onto the station platform. This is the end. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-fallback/2-1.txt b/libs/narrator/test/units/choices-fallback/2-1.txt new file mode 100644 index 0000000..3547c9b --- /dev/null +++ b/libs/narrator/test/units/choices-fallback/2-1.txt @@ -0,0 +1,11 @@ +You search desperately for a friendly face in the crowd. + +1) The woman in the hat? +>) The man with the briefcase? + +The man with the briefcase looks disgusted as you stumble past him. You search desperately for a friendly face in the crowd. + +>) The woman in the hat? + +The woman in the hat pushes you roughly aside. You search desperately for a friendly face in the crowd. +But it is too late: you collapse onto the station platform. This is the end. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-sticky.ink b/libs/narrator/test/units/choices-sticky.ink new file mode 100644 index 0000000..d380ffd --- /dev/null +++ b/libs/narrator/test/units/choices-sticky.ink @@ -0,0 +1,13 @@ +-> homers_couch + +=== homers_couch === + + [Eat another donut] + You eat another donut. -> fine + * [Get off the couch] + You struggle up off the couch to go and compose epic poetry. + -> END + +=== fine === +* Ok, is it fine? -> homers_couch +* Ok, it's fine! -> homers_couch +* -> END \ No newline at end of file diff --git a/libs/narrator/test/units/choices-sticky/1-1-1-1-1.txt b/libs/narrator/test/units/choices-sticky/1-1-1-1-1.txt new file mode 100644 index 0000000..9088a96 --- /dev/null +++ b/libs/narrator/test/units/choices-sticky/1-1-1-1-1.txt @@ -0,0 +1,24 @@ + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +>) Ok, is it fine? +2) Ok, it's fine! + +Ok, is it fine? + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +>) Ok, it's fine! + +Ok, it's fine! + +>) Eat another donut +2) Get off the couch + +You eat another donut. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-sticky/1-1-1-1-2.txt b/libs/narrator/test/units/choices-sticky/1-1-1-1-2.txt new file mode 100644 index 0000000..b6d0114 --- /dev/null +++ b/libs/narrator/test/units/choices-sticky/1-1-1-1-2.txt @@ -0,0 +1,24 @@ + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +>) Ok, is it fine? +2) Ok, it's fine! + +Ok, is it fine? + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +>) Ok, it's fine! + +Ok, it's fine! + +1) Eat another donut +>) Get off the couch + +You struggle up off the couch to go and compose epic poetry. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-sticky/1-1-2.txt b/libs/narrator/test/units/choices-sticky/1-1-2.txt new file mode 100644 index 0000000..2dcc036 --- /dev/null +++ b/libs/narrator/test/units/choices-sticky/1-1-2.txt @@ -0,0 +1,15 @@ + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +>) Ok, is it fine? +2) Ok, it's fine! + +Ok, is it fine? + +1) Eat another donut +>) Get off the couch + +You struggle up off the couch to go and compose epic poetry. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-sticky/1-2-1-1-1.txt b/libs/narrator/test/units/choices-sticky/1-2-1-1-1.txt new file mode 100644 index 0000000..b4185be --- /dev/null +++ b/libs/narrator/test/units/choices-sticky/1-2-1-1-1.txt @@ -0,0 +1,24 @@ + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +1) Ok, is it fine? +>) Ok, it's fine! + +Ok, it's fine! + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +>) Ok, is it fine? + +Ok, is it fine? + +>) Eat another donut +2) Get off the couch + +You eat another donut. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-sticky/1-2-1-1-2.txt b/libs/narrator/test/units/choices-sticky/1-2-1-1-2.txt new file mode 100644 index 0000000..5ac3724 --- /dev/null +++ b/libs/narrator/test/units/choices-sticky/1-2-1-1-2.txt @@ -0,0 +1,24 @@ + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +1) Ok, is it fine? +>) Ok, it's fine! + +Ok, it's fine! + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +>) Ok, is it fine? + +Ok, is it fine? + +1) Eat another donut +>) Get off the couch + +You struggle up off the couch to go and compose epic poetry. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-sticky/1-2-2.txt b/libs/narrator/test/units/choices-sticky/1-2-2.txt new file mode 100644 index 0000000..895a90c --- /dev/null +++ b/libs/narrator/test/units/choices-sticky/1-2-2.txt @@ -0,0 +1,15 @@ + +>) Eat another donut +2) Get off the couch + +You eat another donut. + +1) Ok, is it fine? +>) Ok, it's fine! + +Ok, it's fine! + +1) Eat another donut +>) Get off the couch + +You struggle up off the couch to go and compose epic poetry. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-sticky/2.txt b/libs/narrator/test/units/choices-sticky/2.txt new file mode 100644 index 0000000..24417c8 --- /dev/null +++ b/libs/narrator/test/units/choices-sticky/2.txt @@ -0,0 +1,5 @@ + +1) Eat another donut +>) Get off the couch + +You struggle up off the couch to go and compose epic poetry. \ No newline at end of file diff --git a/libs/narrator/test/units/choices-tags.ink b/libs/narrator/test/units/choices-tags.ink new file mode 100644 index 0000000..a2acdee --- /dev/null +++ b/libs/narrator/test/units/choices-tags.ink @@ -0,0 +1,6 @@ +Let's try an experemental feature: +The Choice Tags! +* Are you seriously? #tag1 + Yeap, absolutely! +* [Eat answer!] #tag2 + Are you hungry? \ No newline at end of file diff --git a/libs/narrator/test/units/choices-tags/1.txt b/libs/narrator/test/units/choices-tags/1.txt new file mode 100644 index 0000000..647b5df --- /dev/null +++ b/libs/narrator/test/units/choices-tags/1.txt @@ -0,0 +1,8 @@ +Let's try an experemental feature: +The Choice Tags! + +>) Are you seriously? #tag1 +2) Eat answer! #tag2 + +Are you seriously? #tag1 +Yeap, absolutely! \ No newline at end of file diff --git a/libs/narrator/test/units/choices-tags/2.txt b/libs/narrator/test/units/choices-tags/2.txt new file mode 100644 index 0000000..4fdfa38 --- /dev/null +++ b/libs/narrator/test/units/choices-tags/2.txt @@ -0,0 +1,7 @@ +Let's try an experemental feature: +The Choice Tags! + +1) Are you seriously? #tag1 +>) Eat answer! #tag2 + +Are you hungry? \ No newline at end of file diff --git a/libs/narrator/test/units/choices-tunnel.ink b/libs/narrator/test/units/choices-tunnel.ink new file mode 100644 index 0000000..3e611a1 --- /dev/null +++ b/libs/narrator/test/units/choices-tunnel.ink @@ -0,0 +1,7 @@ +* [Master] ->master-> # test +Okay... + +=== master +"What's that?" my master asked again. +* I don't know. + ->-> \ No newline at end of file diff --git a/libs/narrator/test/units/choices-tunnel.txt b/libs/narrator/test/units/choices-tunnel.txt new file mode 100644 index 0000000..213394e --- /dev/null +++ b/libs/narrator/test/units/choices-tunnel.txt @@ -0,0 +1,9 @@ + +>) Master #test + +"What's that?" my master asked again. + +>) I don't know. + +I don't know. +Okay... \ No newline at end of file diff --git a/libs/narrator/test/units/comments.ink b/libs/narrator/test/units/comments.ink new file mode 100644 index 0000000..c46a01a --- /dev/null +++ b/libs/narrator/test/units/comments.ink @@ -0,0 +1,15 @@ +"What do you make of this?" she asked. + +// A simple comment + +"I couldn't possibly comment," I replied. // A simple comment after text + +/* + ... or an unlimited block of text +*/ + +Before comment ... /* A comment */ ... and after. Must be in one line, but right now it's known limitation. + +TODO: This TODO will not be printed. + +Text before TODO. TODO: This TODO will be printed. \ No newline at end of file diff --git a/libs/narrator/test/units/comments.txt b/libs/narrator/test/units/comments.txt new file mode 100644 index 0000000..5d9cce6 --- /dev/null +++ b/libs/narrator/test/units/comments.txt @@ -0,0 +1,5 @@ +"What do you make of this?" she asked. +"I couldn't possibly comment," I replied. +Before comment ... +... and after. Must be in one line, but right now it's known limitation. +Text before TODO. TODO: This TODO will be printed. \ No newline at end of file diff --git a/libs/narrator/test/units/conditions-inline.ink b/libs/narrator/test/units/conditions-inline.ink new file mode 100644 index 0000000..cd754a5 --- /dev/null +++ b/libs/narrator/test/units/conditions-inline.ink @@ -0,0 +1,15 @@ +VAR mood = 10 +VAR midnight = true + +-> simple + +=== simple +Simple condition: { mood > 20 : Hmm... { mood > 40 : Wow! } I was feeling positive enough. } Okay. +~ mood += 20 +{ mood > 50 : -> complex | -> simple } + +=== complex +Complex condition with midnight. { midnight : Wow! { midnight : Nice! | Bad! } | { not midnight : Very nice! | Very bad! } This is the end. } +{ midnight != true : -> END } +~ midnight = false +-> complex \ No newline at end of file diff --git a/libs/narrator/test/units/conditions-inline.txt b/libs/narrator/test/units/conditions-inline.txt new file mode 100644 index 0000000..3280ebc --- /dev/null +++ b/libs/narrator/test/units/conditions-inline.txt @@ -0,0 +1,5 @@ +Simple condition: Okay. +Simple condition: Hmm... I was feeling positive enough. Okay. +Simple condition: Hmm... Wow! I was feeling positive enough. Okay. +Complex condition with midnight. Wow! Nice! +Complex condition with midnight. Very nice! This is the end. \ No newline at end of file diff --git a/libs/narrator/test/units/conditions-switch.ink b/libs/narrator/test/units/conditions-switch.ink new file mode 100644 index 0000000..aedde7b --- /dev/null +++ b/libs/narrator/test/units/conditions-switch.ink @@ -0,0 +1,78 @@ +VAR foo = false +VAR x = 0 + +-> simple + +== simple +{not foo: + Hello! + } +{ foo : +True! -> choice +- else: +False! } +~ foo = not foo +-> simple + +=== choice + +{ true : + * [Answer] -> nested_inline +} + +== nested_inline +{ + - foo : { foo: True! | False! } + Text here. + - else: Badaboom! + ...again. -> nested_block +} +~ foo = not foo +-> nested_inline + + +== nested_block +I love you. { true: + And what about you? + { + - false: + False! + - true: + * Choice -> tags + } + ... +} Something. + +== tags +{ +- not foo: + text { true : x = { x } } #success +- else: + -> not_a_label +} +~ foo = not foo +-> tags + +== not_a_label +{ +- foo: suc... + success #success +- x < 0: mid... + middle + text -> END +- else: els... + (notLabel) else + -> switch +} +~ foo = not foo +-> not_a_label + +== switch +{ x: +- 0: zero +- 1: one +- 2: two +- else: lots -> END +} +~ x++ +-> switch \ No newline at end of file diff --git a/libs/narrator/test/units/conditions-switch.txt b/libs/narrator/test/units/conditions-switch.txt new file mode 100644 index 0000000..93f2d4b --- /dev/null +++ b/libs/narrator/test/units/conditions-switch.txt @@ -0,0 +1,23 @@ +Hello! +False! +True! + +>) Answer + +True! +Text here. +Badaboom! +...again. I love you. +And what about you? + +>) Choice + +Choice text x = 0 #success +suc... +success #success +els... +(notLabel) else +zero +one +two +lots \ No newline at end of file diff --git a/libs/narrator/test/units/constants.ink b/libs/narrator/test/units/constants.ink new file mode 100644 index 0000000..a59fff7 --- /dev/null +++ b/libs/narrator/test/units/constants.ink @@ -0,0 +1,26 @@ +CONST STRING_EXAMPLE = "This is the string constant" +CONST BOOLEAN_EXAMLPE_TRUE = true +CONST BOOLEAN_EXAMLPE_FALSE = false + +CONST LOBBY = 1 +CONST STAIRCASE = 2 +CONST HALLWAY = 3 +CONST HELD_BY_AGENT = -1 + +VAR secret_agent_location = LOBBY +VAR suitcase_location = HALLWAY + +{ STRING_EXAMPLE }. One is { BOOLEAN_EXAMLPE_TRUE }. Zero is { BOOLEAN_EXAMLPE_FALSE }. + +-> report_progress + +=== report_progress === +{ secret_agent_location == suitcase_location: + The secret agent grabs the suitcase! + ~ suitcase_location = HELD_BY_AGENT + +- secret_agent_location < suitcase_location: + The secret agent moves forward. + ~ secret_agent_location++ + -> report_progress +} \ No newline at end of file diff --git a/libs/narrator/test/units/constants.txt b/libs/narrator/test/units/constants.txt new file mode 100644 index 0000000..84277c0 --- /dev/null +++ b/libs/narrator/test/units/constants.txt @@ -0,0 +1,4 @@ +This is the string constant. One is 1. Zero is 0. +The secret agent moves forward. +The secret agent moves forward. +The secret agent grabs the suitcase! \ No newline at end of file diff --git a/libs/narrator/test/units/escape.ink b/libs/narrator/test/units/escape.ink new file mode 100644 index 0000000..2ee0408 --- /dev/null +++ b/libs/narrator/test/units/escape.ink @@ -0,0 +1 @@ +The \| dark \{grass\} is soft under your feet. \ No newline at end of file diff --git a/libs/narrator/test/units/escape.txt b/libs/narrator/test/units/escape.txt new file mode 100644 index 0000000..722e971 --- /dev/null +++ b/libs/narrator/test/units/escape.txt @@ -0,0 +1 @@ +The | dark {grass} is soft under your feet. \ No newline at end of file diff --git a/libs/narrator/test/units/expressions.ink b/libs/narrator/test/units/expressions.ink new file mode 100644 index 0000000..83f6881 --- /dev/null +++ b/libs/narrator/test/units/expressions.ink @@ -0,0 +1,7 @@ +VAR friendly_name_of_player = "Katy" +VAR age = "32" +VAR dalmatians_count = 101 + +My friends call me {friendly_name_of_player}. I'm { age } years old. +Wow, 2 x 2 = { 2 * 2 }! +I have { dalmatians_count / 101 } dalmatian. \ No newline at end of file diff --git a/libs/narrator/test/units/expressions.txt b/libs/narrator/test/units/expressions.txt new file mode 100644 index 0000000..0ac77a1 --- /dev/null +++ b/libs/narrator/test/units/expressions.txt @@ -0,0 +1,3 @@ +My friends call me Katy. I'm 32 years old. +Wow, 2 x 2 = 4! +I have 1 dalmatian. \ No newline at end of file diff --git a/libs/narrator/test/units/functions.ink b/libs/narrator/test/units/functions.ink new file mode 100644 index 0000000..aae6b82 --- /dev/null +++ b/libs/narrator/test/units/functions.ink @@ -0,0 +1,23 @@ +{foo(): ->continue} +something wrong 1 + +-(continue) +{boo(): + something wrong 2 +} + +sum: {test(2, 3)} + +-> END + +=== function test(a, b) === +first param is {a} +second param is {b} +~return a + b + +=== function foo() === +~return true + +=== function boo() === +~return + diff --git a/libs/narrator/test/units/functions.txt b/libs/narrator/test/units/functions.txt new file mode 100644 index 0000000..f9f89c8 --- /dev/null +++ b/libs/narrator/test/units/functions.txt @@ -0,0 +1,3 @@ +first param is 2 +second param is 3 +sum: 5 \ No newline at end of file diff --git a/libs/narrator/test/units/gather.ink b/libs/narrator/test/units/gather.ink new file mode 100644 index 0000000..6f12841 --- /dev/null +++ b/libs/narrator/test/units/gather.ink @@ -0,0 +1,7 @@ +I looked at Monsieur Fogg +* ... and I could contain myself no longer. + 'What is the purpose of our journey, Monsieur?' + 'A wager,' he replied. +- +we passed the day in silence. +-> END \ No newline at end of file diff --git a/libs/narrator/test/units/gather.txt b/libs/narrator/test/units/gather.txt new file mode 100644 index 0000000..e9f435b --- /dev/null +++ b/libs/narrator/test/units/gather.txt @@ -0,0 +1,8 @@ +I looked at Monsieur Fogg + +>) ... and I could contain myself no longer. + +... and I could contain myself no longer. +'What is the purpose of our journey, Monsieur?' +'A wager,' he replied. +we passed the day in silence. \ No newline at end of file diff --git a/libs/narrator/test/units/inclusions.ink b/libs/narrator/test/units/inclusions.ink new file mode 100644 index 0000000..c6a9530 --- /dev/null +++ b/libs/narrator/test/units/inclusions.ink @@ -0,0 +1,3 @@ +INCLUDE text-line.ink + +A line after a line \ No newline at end of file diff --git a/libs/narrator/test/units/inclusions.txt b/libs/narrator/test/units/inclusions.txt new file mode 100644 index 0000000..b5d836f --- /dev/null +++ b/libs/narrator/test/units/inclusions.txt @@ -0,0 +1,2 @@ +Hello, world! I am a string. +A line after a line \ No newline at end of file diff --git a/libs/narrator/test/units/knots.ink b/libs/narrator/test/units/knots.ink new file mode 100644 index 0000000..a471d6c --- /dev/null +++ b/libs/narrator/test/units/knots.ink @@ -0,0 +1,13 @@ +-> back_in_london + +== back_in_london === + +We arrived into London at 9.45pm exactly. +-> hurry_home + +=== hurry_home = +We hurried home to Savile Row -> as_fast_as_we_could + +====== as_fast_as_we_could +as fast as we could. -> END + diff --git a/libs/narrator/test/units/knots.txt b/libs/narrator/test/units/knots.txt new file mode 100644 index 0000000..bc8954d --- /dev/null +++ b/libs/narrator/test/units/knots.txt @@ -0,0 +1,2 @@ +We arrived into London at 9.45pm exactly. +We hurried home to Savile Row as fast as we could. \ No newline at end of file diff --git a/libs/narrator/test/units/labels-choices.ink b/libs/narrator/test/units/labels-choices.ink new file mode 100644 index 0000000..abfd5a6 --- /dev/null +++ b/libs/narrator/test/units/labels-choices.ink @@ -0,0 +1,32 @@ +-> meet_guard + +=== meet_guard === +The guard frowns at you. + +* (greet) [Greet him] + 'Greetings.' +* (get_out) 'Get out of my way[.'],' you tell the guard. + +- 'Hmm,' replies the guard. + +* {greet} 'Having a nice day?' // only if you greeted him + +* 'Hmm?'[] you reply. + +* {get_out} [Shove him aside] // only if you threatened him + You shove him sharply. He stares in reply, and draws his sword! + -> fight_guard // this route diverts out of the weave + +- 'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?' + + +=== fight_guard === + +-> throw_something + += throw_something +* (rock) [Throw rock at guard] -> throw +* (sand) [Throw sand at guard] -> throw + += throw +You hurl {throw_something.rock:a rock|a handful of sand} at the guard. \ No newline at end of file diff --git a/libs/narrator/test/units/labels-choices/1-1.txt b/libs/narrator/test/units/labels-choices/1-1.txt new file mode 100644 index 0000000..e544378 --- /dev/null +++ b/libs/narrator/test/units/labels-choices/1-1.txt @@ -0,0 +1,13 @@ +The guard frowns at you. + +>) Greet him +2) 'Get out of my way.' + +'Greetings.' +'Hmm,' replies the guard. + +>) 'Having a nice day?' +2) 'Hmm?' + +'Having a nice day?' +'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?' \ No newline at end of file diff --git a/libs/narrator/test/units/labels-choices/1-2.txt b/libs/narrator/test/units/labels-choices/1-2.txt new file mode 100644 index 0000000..c4c6cc9 --- /dev/null +++ b/libs/narrator/test/units/labels-choices/1-2.txt @@ -0,0 +1,13 @@ +The guard frowns at you. + +>) Greet him +2) 'Get out of my way.' + +'Greetings.' +'Hmm,' replies the guard. + +1) 'Having a nice day?' +>) 'Hmm?' + +'Hmm?' you reply. +'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?' \ No newline at end of file diff --git a/libs/narrator/test/units/labels-choices/2-1.txt b/libs/narrator/test/units/labels-choices/2-1.txt new file mode 100644 index 0000000..6a2446f --- /dev/null +++ b/libs/narrator/test/units/labels-choices/2-1.txt @@ -0,0 +1,13 @@ +The guard frowns at you. + +1) Greet him +>) 'Get out of my way.' + +'Get out of my way,' you tell the guard. +'Hmm,' replies the guard. + +>) 'Hmm?' +2) Shove him aside + +'Hmm?' you reply. +'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?' \ No newline at end of file diff --git a/libs/narrator/test/units/labels-choices/2-2-1.txt b/libs/narrator/test/units/labels-choices/2-2-1.txt new file mode 100644 index 0000000..1a39fc5 --- /dev/null +++ b/libs/narrator/test/units/labels-choices/2-2-1.txt @@ -0,0 +1,17 @@ +The guard frowns at you. + +1) Greet him +>) 'Get out of my way.' + +'Get out of my way,' you tell the guard. +'Hmm,' replies the guard. + +1) 'Hmm?' +>) Shove him aside + +You shove him sharply. He stares in reply, and draws his sword! + +>) Throw rock at guard +2) Throw sand at guard + +You hurl a rock at the guard. \ No newline at end of file diff --git a/libs/narrator/test/units/labels-choices/2-2-2.txt b/libs/narrator/test/units/labels-choices/2-2-2.txt new file mode 100644 index 0000000..1e639d8 --- /dev/null +++ b/libs/narrator/test/units/labels-choices/2-2-2.txt @@ -0,0 +1,17 @@ +The guard frowns at you. + +1) Greet him +>) 'Get out of my way.' + +'Get out of my way,' you tell the guard. +'Hmm,' replies the guard. + +1) 'Hmm?' +>) Shove him aside + +You shove him sharply. He stares in reply, and draws his sword! + +1) Throw rock at guard +>) Throw sand at guard + +You hurl a handful of sand at the guard. \ No newline at end of file diff --git a/libs/narrator/test/units/labels-nested.ink b/libs/narrator/test/units/labels-nested.ink new file mode 100644 index 0000000..667801c --- /dev/null +++ b/libs/narrator/test/units/labels-nested.ink @@ -0,0 +1,22 @@ +-> dialog_start +==dialog_start== + +* [Tell me about the mister] +- - (dima) +- - His name is Dima. +* * {not vika} [Tell me about Vika] + -> vika +* * [Finish conversation] + -> stop_dialog + +* [Tell me about the missis] +- - (vika) + Her name is Vika. +* * {not dima} [Tell me about Dima] + -> dima +* * [Finish conversation] + -> stop_dialog + +==stop_dialog== +That's all. +-> END \ No newline at end of file diff --git a/libs/narrator/test/units/labels-nested/1-1-1.txt b/libs/narrator/test/units/labels-nested/1-1-1.txt new file mode 100644 index 0000000..0bd52b5 --- /dev/null +++ b/libs/narrator/test/units/labels-nested/1-1-1.txt @@ -0,0 +1,14 @@ + +>) Tell me about the mister +2) Tell me about the missis + +His name is Dima. + +>) Tell me about Vika +2) Finish conversation + +Her name is Vika. + +>) Finish conversation + +That's all. \ No newline at end of file diff --git a/libs/narrator/test/units/labels-nested/1-2.txt b/libs/narrator/test/units/labels-nested/1-2.txt new file mode 100644 index 0000000..7e1caa2 --- /dev/null +++ b/libs/narrator/test/units/labels-nested/1-2.txt @@ -0,0 +1,10 @@ + +>) Tell me about the mister +2) Tell me about the missis + +His name is Dima. + +1) Tell me about Vika +>) Finish conversation + +That's all. \ No newline at end of file diff --git a/libs/narrator/test/units/labels-nested/2-1-1.txt b/libs/narrator/test/units/labels-nested/2-1-1.txt new file mode 100644 index 0000000..f9156dc --- /dev/null +++ b/libs/narrator/test/units/labels-nested/2-1-1.txt @@ -0,0 +1,14 @@ + +1) Tell me about the mister +>) Tell me about the missis + +Her name is Vika. + +>) Tell me about Dima +2) Finish conversation + +His name is Dima. + +>) Finish conversation + +That's all. \ No newline at end of file diff --git a/libs/narrator/test/units/labels-nested/2-2.txt b/libs/narrator/test/units/labels-nested/2-2.txt new file mode 100644 index 0000000..6ed2c42 --- /dev/null +++ b/libs/narrator/test/units/labels-nested/2-2.txt @@ -0,0 +1,10 @@ + +1) Tell me about the mister +>) Tell me about the missis + +Her name is Vika. + +1) Tell me about Dima +>) Finish conversation + +That's all. \ No newline at end of file diff --git a/libs/narrator/test/units/lists-basic.ink b/libs/narrator/test/units/lists-basic.ink new file mode 100644 index 0000000..86b9e09 --- /dev/null +++ b/libs/narrator/test/units/lists-basic.ink @@ -0,0 +1,57 @@ +LIST daysOfTheWeek = Monday, Tuesday, Wednesday, Thursday, Friday +VAR today = Monday +VAR tomorrow = Tuesday + +LIST heatedWaterStates = cold, boiling, recently_boiled +VAR kettleState = cold +VAR potState = cold + +LIST colours = red, green, blue, purple +LIST moods = mad, happy, blue +VAR status = colours.blue + +LIST volumeLevel = off, quiet, medium, loud, deafening +VAR lecturersVolume = quiet +VAR murmurersVolume = quiet + +LIST Numbers = one, two, three +VAR cats = one + +~ cats = Numbers(2) // score will be "two" +We have { cats } cats. + +-> today +== today + +Today is { today }. Tomorrow is { tomorrow }. + +... +-> kitchen +=== kitchen + + +- Hm, kettle is { kettleState } and pot is { potState }. +~ kettleState = boiling +- Now kettle is { kettleState } and pot is { potState }. + +... +-> status +=== status + +Binary values are { status == colours.blue } and { status == moods.blue } + +... +-> lecture +=== lecture + +{ lecturersVolume < deafening: + ~ lecturersVolume++ + The lecturer's voice becomes {lecturersVolume}. + The lecturer has {LIST_VALUE(deafening) - LIST_VALUE(lecturersVolume)} notches still available to him. + + { lecturersVolume > murmurersVolume: + ~ murmurersVolume++ + The murmuring gets louder. + } + -> lecture +} \ No newline at end of file diff --git a/libs/narrator/test/units/lists-basic.txt b/libs/narrator/test/units/lists-basic.txt new file mode 100644 index 0000000..d4f7891 --- /dev/null +++ b/libs/narrator/test/units/lists-basic.txt @@ -0,0 +1,17 @@ +We have two cats. +Today is Monday. Tomorrow is Tuesday. +... +Hm, kettle is cold and pot is cold. +Now kettle is boiling and pot is cold. +... +Binary values are 1 and 0 +... +The lecturer's voice becomes medium. +The lecturer has 2 notches still available to him. +The murmuring gets louder. +The lecturer's voice becomes loud. +The lecturer has 1 notches still available to him. +The murmuring gets louder. +The lecturer's voice becomes deafening. +The lecturer has 0 notches still available to him. +The murmuring gets louder. \ No newline at end of file diff --git a/libs/narrator/test/units/lists-operators.ink b/libs/narrator/test/units/lists-operators.ink new file mode 100644 index 0000000..5244c20 --- /dev/null +++ b/libs/narrator/test/units/lists-operators.ink @@ -0,0 +1,66 @@ +LIST DoctorsInSurgery = Adams, Bernard, (Cartwright), Denver, Eamonn + +Cartwright == { DoctorsInSurgery } + +~ DoctorsInSurgery = (Adams, Bernard) +Adams, Bernard == { DoctorsInSurgery } + +~ DoctorsInSurgery = () +Empty == { DoctorsInSurgery } Empty + +~ DoctorsInSurgery = DoctorsInSurgery + Adams +Adams == { DoctorsInSurgery } + +~ DoctorsInSurgery += Eamonn +Adams, Eamonn == { DoctorsInSurgery } + +~ DoctorsInSurgery -= Eamonn +Adams == { DoctorsInSurgery } + +~ DoctorsInSurgery += (Eamonn, Denver) +Adams, Eamonn, Denver == { DoctorsInSurgery } + +~ DoctorsInSurgery -= (Adams, Eamonn, Denver) +Empty == { DoctorsInSurgery } Empty again! + +... +~ DoctorsInSurgery = (Adams, Cartwright) +{LIST_COUNT(DoctorsInSurgery)} doctors in surgery. +First is {LIST_MIN(DoctorsInSurgery)}. +Last is {LIST_MAX(DoctorsInSurgery)}. + +Random seed = 1 { SEED_RANDOM(1)} +A random doctor: {LIST_RANDOM(DoctorsInSurgery)} + +Working time! { DoctorsInSurgery: The surgery is open today. | Everyone has gone home. } +~ DoctorsInSurgery = () +It's to late. { DoctorsInSurgery: The surgery is open today. | Everyone has gone home. } + +~ DoctorsInSurgery = (Adams, Bernard) +{ DoctorsInSurgery == (Adams, Bernard): + Dr Adams and Dr Bernard are having a loud argument in one corner. +} + +{ DoctorsInSurgery ? (Adams, Bernard): + Dr Adams and Dr Bernard are having a hushed argument in one corner. +} + +~ DoctorsInSurgery += Eamonn + +{ DoctorsInSurgery != (Adams, Bernard): + At least Adams and Bernard aren't arguing. +} + +{ DoctorsInSurgery has Eamonn: + Dr Eamonn is polishing his glasses. +} + +{ DoctorsInSurgery !? (Adams, Bernard) : Yeap, Adams and Bernard are outside. | Nope, Adams and Bernard are here. } + +All the doctors ({LIST_COUNT(LIST_ALL(DoctorsInSurgery))}): { LIST_ALL(DoctorsInSurgery) } +All the doctors again: { LIST_ALL(Adams) } + +VAR myList = () +~ myList = DoctorsInSurgery() +~ myList += Adams +{ myList } here! \ No newline at end of file diff --git a/libs/narrator/test/units/lists-operators.txt b/libs/narrator/test/units/lists-operators.txt new file mode 100644 index 0000000..81d7e41 --- /dev/null +++ b/libs/narrator/test/units/lists-operators.txt @@ -0,0 +1,24 @@ +Cartwright == Cartwright +Adams, Bernard == Adams, Bernard +Empty == Empty +Adams == Adams +Adams, Eamonn == Adams, Eamonn +Adams == Adams +Adams, Eamonn, Denver == Adams, Denver, Eamonn +Empty == Empty again! +... +2 doctors in surgery. +First is Adams. +Last is Cartwright. +Random seed = 1 +A random doctor: Adams +Working time! The surgery is open today. +It's to late. Everyone has gone home. +Dr Adams and Dr Bernard are having a loud argument in one corner. +Dr Adams and Dr Bernard are having a hushed argument in one corner. +At least Adams and Bernard aren't arguing. +Dr Eamonn is polishing his glasses. +Nope, Adams and Bernard are here. +All the doctors (5): Adams, Bernard, Cartwright, Denver, Eamonn +All the doctors again: Adams, Bernard, Cartwright, Denver, Eamonn +Adams here! \ No newline at end of file diff --git a/libs/narrator/test/units/lists-queries.ink b/libs/narrator/test/units/lists-queries.ink new file mode 100644 index 0000000..fb6c2f9 --- /dev/null +++ b/libs/narrator/test/units/lists-queries.ink @@ -0,0 +1,50 @@ +LIST GuardsOnDuty = (Smith), (Jones), Carter, Braithwaite +LIST CoreValues = strength, courage, compassion, greed, nepotism, delusions_of_godhood + +Normal: { GuardsOnDuty } +~ GuardsOnDuty = LIST_INVERT(GuardsOnDuty) +Inverted: { GuardsOnDuty } + +Range: { LIST_RANGE(GuardsOnDuty, Jones, Carter) } + +... +VAR desiredValues = (strength, courage, compassion, nepotism ) +VAR actualValues = ( greed, nepotism, delusions_of_godhood ) +{desiredValues ^ actualValues: The new president has at least one desirable quality. A cold {desiredValues ^ actualValues}.} +{LIST_COUNT(desiredValues ^ actualValues) == 1: Correction, the new president has only one desirable quality. {desiredValues ^ actualValues == nepotism: It's the scary one.}} + +... +LIST Characters = Alfred, Batman, Robin +LIST Props = champagne_glass, newspaper + +VAR BallroomContents = (Alfred, Batman, newspaper) +VAR HallwayContents = (Robin, champagne_glass) +VAR roomState = (Robin, champagne_glass) + +-> room +== room +{ roomState ? Alfred: Alfred is here, standing quietly in a corner. } { roomState ? Batman: Batman's presence dominates all. } { roomState ? Robin: Robin is all but forgotten. } + <> { roomState ? champagne_glass: A champagne glass lies discarded on the floor. } { roomState ? newspaper: On one table, a headline blares out WHO IS THE BATMAN? AND *WHO* IS HIS BARELY-REMEMBERED ASSISTANT? } + +{ roomState == BallroomContents : + -> letters +- else: + ~ roomState = BallroomContents + -> room +} + +== letters +LIST Letters = a,b,c +LIST Numbers = one, two, three +VAR mixedList = (a, three, c) + +... +{LIST_ALL(mixedList)} // a, one, b, two, c, three +{LIST_COUNT(mixedList)} // 3 +{LIST_MIN(mixedList)} // a +{LIST_MAX(mixedList)} // c +{mixedList ? (a,b) } // 0 (false) +{mixedList ^ LIST_ALL(a)} // a, c +{ mixedList >= (one, a) } // 1 (true) +{ mixedList > (three) } // 0 (false) +{ LIST_INVERT(mixedList) } // one, b, two \ No newline at end of file diff --git a/libs/narrator/test/units/lists-queries.txt b/libs/narrator/test/units/lists-queries.txt new file mode 100644 index 0000000..9073f8b --- /dev/null +++ b/libs/narrator/test/units/lists-queries.txt @@ -0,0 +1,19 @@ +Normal: Smith, Jones +Inverted: Carter, Braithwaite +Range: Carter +... +The new president has at least one desirable quality. A cold nepotism. +Correction, the new president has only one desirable quality. It's the scary one. +... +Robin is all but forgotten. A champagne glass lies discarded on the floor. +Alfred is here, standing quietly in a corner. Batman's presence dominates all. On one table, a headline blares out WHO IS THE BATMAN? AND *WHO* IS HIS BARELY-REMEMBERED ASSISTANT? +... +one, a, two, b, three, c +3 +a +c +0 +a, c +1 +0 +one, two, b \ No newline at end of file diff --git a/libs/narrator/test/units/loop.ink b/libs/narrator/test/units/loop.ink new file mode 100644 index 0000000..2c6e5db --- /dev/null +++ b/libs/narrator/test/units/loop.ink @@ -0,0 +1,17 @@ +- (opts) + * 'Can I get a uniform from somewhere?'[] you ask the cheerful guard. + 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + * 'Tell me about the security system.' + 'It's ancient,' the guard assures you. 'Old as coal.' + * 'Are there dogs?' + 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + // We require the player to ask at least one question + * {loop} [Enough talking] + -> done +- (loop) + // loop a few times before the guard gets bored + { -> opts | -> opts | } + He scratches his head. + 'Well, can't stand around talking all day,' he declares. +- (done) + You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/1-1-1.txt b/libs/narrator/test/units/loop/1-1-1.txt new file mode 100644 index 0000000..7a3be6e --- /dev/null +++ b/libs/narrator/test/units/loop/1-1-1.txt @@ -0,0 +1,23 @@ + +>) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + +>) 'Tell me about the security system.' +2) 'Are there dogs?' +3) Enough talking + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' + +>) 'Are there dogs?' +2) Enough talking + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' +He scratches his head. +'Well, can't stand around talking all day,' he declares. +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/1-1-2.txt b/libs/narrator/test/units/loop/1-1-2.txt new file mode 100644 index 0000000..e2743bc --- /dev/null +++ b/libs/narrator/test/units/loop/1-1-2.txt @@ -0,0 +1,19 @@ + +>) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + +>) 'Tell me about the security system.' +2) 'Are there dogs?' +3) Enough talking + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' + +1) 'Are there dogs?' +>) Enough talking + +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/1-2-1.txt b/libs/narrator/test/units/loop/1-2-1.txt new file mode 100644 index 0000000..41d68b9 --- /dev/null +++ b/libs/narrator/test/units/loop/1-2-1.txt @@ -0,0 +1,23 @@ + +>) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + +1) 'Tell me about the security system.' +>) 'Are there dogs?' +3) Enough talking + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + +>) 'Tell me about the security system.' +2) Enough talking + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' +He scratches his head. +'Well, can't stand around talking all day,' he declares. +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/1-2-2.txt b/libs/narrator/test/units/loop/1-2-2.txt new file mode 100644 index 0000000..f594a33 --- /dev/null +++ b/libs/narrator/test/units/loop/1-2-2.txt @@ -0,0 +1,19 @@ + +>) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + +1) 'Tell me about the security system.' +>) 'Are there dogs?' +3) Enough talking + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + +1) 'Tell me about the security system.' +>) Enough talking + +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/1-3.txt b/libs/narrator/test/units/loop/1-3.txt new file mode 100644 index 0000000..7ac6d09 --- /dev/null +++ b/libs/narrator/test/units/loop/1-3.txt @@ -0,0 +1,13 @@ + +>) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + +1) 'Tell me about the security system.' +2) 'Are there dogs?' +>) Enough talking + +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/2-1-1.txt b/libs/narrator/test/units/loop/2-1-1.txt new file mode 100644 index 0000000..cbc865e --- /dev/null +++ b/libs/narrator/test/units/loop/2-1-1.txt @@ -0,0 +1,23 @@ + +1) 'Can I get a uniform from somewhere?' +>) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' + +>) 'Can I get a uniform from somewhere?' +2) 'Are there dogs?' +3) Enough talking + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + +>) 'Are there dogs?' +2) Enough talking + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' +He scratches his head. +'Well, can't stand around talking all day,' he declares. +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/2-1-2.txt b/libs/narrator/test/units/loop/2-1-2.txt new file mode 100644 index 0000000..b03adf4 --- /dev/null +++ b/libs/narrator/test/units/loop/2-1-2.txt @@ -0,0 +1,19 @@ + +1) 'Can I get a uniform from somewhere?' +>) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' + +>) 'Can I get a uniform from somewhere?' +2) 'Are there dogs?' +3) Enough talking + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + +1) 'Are there dogs?' +>) Enough talking + +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/2-2-1.txt b/libs/narrator/test/units/loop/2-2-1.txt new file mode 100644 index 0000000..e19ce1b --- /dev/null +++ b/libs/narrator/test/units/loop/2-2-1.txt @@ -0,0 +1,23 @@ + +1) 'Can I get a uniform from somewhere?' +>) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' + +1) 'Can I get a uniform from somewhere?' +>) 'Are there dogs?' +3) Enough talking + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + +>) 'Can I get a uniform from somewhere?' +2) Enough talking + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' +He scratches his head. +'Well, can't stand around talking all day,' he declares. +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/2-2-2.txt b/libs/narrator/test/units/loop/2-2-2.txt new file mode 100644 index 0000000..6679c31 --- /dev/null +++ b/libs/narrator/test/units/loop/2-2-2.txt @@ -0,0 +1,19 @@ + +1) 'Can I get a uniform from somewhere?' +>) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' + +1) 'Can I get a uniform from somewhere?' +>) 'Are there dogs?' +3) Enough talking + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + +1) 'Can I get a uniform from somewhere?' +>) Enough talking + +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/2-3.txt b/libs/narrator/test/units/loop/2-3.txt new file mode 100644 index 0000000..f48d138 --- /dev/null +++ b/libs/narrator/test/units/loop/2-3.txt @@ -0,0 +1,13 @@ + +1) 'Can I get a uniform from somewhere?' +>) 'Tell me about the security system.' +3) 'Are there dogs?' + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' + +1) 'Can I get a uniform from somewhere?' +2) 'Are there dogs?' +>) Enough talking + +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/3-1-1.txt b/libs/narrator/test/units/loop/3-1-1.txt new file mode 100644 index 0000000..63ecf2c --- /dev/null +++ b/libs/narrator/test/units/loop/3-1-1.txt @@ -0,0 +1,23 @@ + +1) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +>) 'Are there dogs?' + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + +>) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +3) Enough talking + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + +>) 'Tell me about the security system.' +2) Enough talking + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' +He scratches his head. +'Well, can't stand around talking all day,' he declares. +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/3-1-2.txt b/libs/narrator/test/units/loop/3-1-2.txt new file mode 100644 index 0000000..e776021 --- /dev/null +++ b/libs/narrator/test/units/loop/3-1-2.txt @@ -0,0 +1,19 @@ + +1) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +>) 'Are there dogs?' + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + +>) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +3) Enough talking + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' + +1) 'Tell me about the security system.' +>) Enough talking + +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/3-2-1.txt b/libs/narrator/test/units/loop/3-2-1.txt new file mode 100644 index 0000000..3ce35b7 --- /dev/null +++ b/libs/narrator/test/units/loop/3-2-1.txt @@ -0,0 +1,23 @@ + +1) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +>) 'Are there dogs?' + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + +1) 'Can I get a uniform from somewhere?' +>) 'Tell me about the security system.' +3) Enough talking + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' + +>) 'Can I get a uniform from somewhere?' +2) Enough talking + +'Can I get a uniform from somewhere?' you ask the cheerful guard. +'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' +He scratches his head. +'Well, can't stand around talking all day,' he declares. +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/3-2-2.txt b/libs/narrator/test/units/loop/3-2-2.txt new file mode 100644 index 0000000..50f8fe6 --- /dev/null +++ b/libs/narrator/test/units/loop/3-2-2.txt @@ -0,0 +1,19 @@ + +1) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +>) 'Are there dogs?' + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + +1) 'Can I get a uniform from somewhere?' +>) 'Tell me about the security system.' +3) Enough talking + +'Tell me about the security system.' +'It's ancient,' the guard assures you. 'Old as coal.' + +1) 'Can I get a uniform from somewhere?' +>) Enough talking + +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/loop/3-3.txt b/libs/narrator/test/units/loop/3-3.txt new file mode 100644 index 0000000..448d14f --- /dev/null +++ b/libs/narrator/test/units/loop/3-3.txt @@ -0,0 +1,13 @@ + +1) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +>) 'Are there dogs?' + +'Are there dogs?' +'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' + +1) 'Can I get a uniform from somewhere?' +2) 'Tell me about the security system.' +>) Enough talking + +You thank the guard, and move away. \ No newline at end of file diff --git a/libs/narrator/test/units/nesting.ink b/libs/narrator/test/units/nesting.ink new file mode 100644 index 0000000..ded7f42 --- /dev/null +++ b/libs/narrator/test/units/nesting.ink @@ -0,0 +1,19 @@ +- I looked at Monsieur Fogg +* ... and I could contain myself no longer. + 'What is the purpose of our journey, Monsieur?' + 'A wager,' he replied. + * * 'A wager!'[] I returned. + He nodded. + * * * 'But surely that is foolishness!' + * * * 'A most serious matter then!' + - - - He nodded again. + * * * 'But can we win?' + 'That is what we will endeavour to find out,' he answered. + * * * 'A modest wager, I trust?' + 'Twenty thousand pounds,' he replied, quite flatly. + * * * I asked nothing further of him then[.], and after a final, polite cough, he offered nothing more to me. <> + * * 'Ah[.'],' I replied, uncertain what I thought. + - - After that, <> +* ... but I said nothing[] and <> +- we passed the day in silence. +- -> END \ No newline at end of file diff --git a/libs/narrator/test/units/nesting/1-1-1-1.txt b/libs/narrator/test/units/nesting/1-1-1-1.txt new file mode 100644 index 0000000..7152089 --- /dev/null +++ b/libs/narrator/test/units/nesting/1-1-1-1.txt @@ -0,0 +1,28 @@ +I looked at Monsieur Fogg + +>) ... and I could contain myself no longer. +2) ... but I said nothing + +... and I could contain myself no longer. +'What is the purpose of our journey, Monsieur?' +'A wager,' he replied. + +>) 'A wager!' +2) 'Ah.' + +'A wager!' I returned. +He nodded. + +>) 'But surely that is foolishness!' +2) 'A most serious matter then!' + +'But surely that is foolishness!' +He nodded again. + +>) 'But can we win?' +2) 'A modest wager, I trust?' +3) I asked nothing further of him then. + +'But can we win?' +'That is what we will endeavour to find out,' he answered. +After that, we passed the day in silence. \ No newline at end of file diff --git a/libs/narrator/test/units/nesting/1-1-1-2.txt b/libs/narrator/test/units/nesting/1-1-1-2.txt new file mode 100644 index 0000000..3866e37 --- /dev/null +++ b/libs/narrator/test/units/nesting/1-1-1-2.txt @@ -0,0 +1,28 @@ +I looked at Monsieur Fogg + +>) ... and I could contain myself no longer. +2) ... but I said nothing + +... and I could contain myself no longer. +'What is the purpose of our journey, Monsieur?' +'A wager,' he replied. + +>) 'A wager!' +2) 'Ah.' + +'A wager!' I returned. +He nodded. + +>) 'But surely that is foolishness!' +2) 'A most serious matter then!' + +'But surely that is foolishness!' +He nodded again. + +1) 'But can we win?' +>) 'A modest wager, I trust?' +3) I asked nothing further of him then. + +'A modest wager, I trust?' +'Twenty thousand pounds,' he replied, quite flatly. +After that, we passed the day in silence. \ No newline at end of file diff --git a/libs/narrator/test/units/nesting/1-1-1-3.txt b/libs/narrator/test/units/nesting/1-1-1-3.txt new file mode 100644 index 0000000..67dc94b --- /dev/null +++ b/libs/narrator/test/units/nesting/1-1-1-3.txt @@ -0,0 +1,26 @@ +I looked at Monsieur Fogg + +>) ... and I could contain myself no longer. +2) ... but I said nothing + +... and I could contain myself no longer. +'What is the purpose of our journey, Monsieur?' +'A wager,' he replied. + +>) 'A wager!' +2) 'Ah.' + +'A wager!' I returned. +He nodded. + +>) 'But surely that is foolishness!' +2) 'A most serious matter then!' + +'But surely that is foolishness!' +He nodded again. + +1) 'But can we win?' +2) 'A modest wager, I trust?' +>) I asked nothing further of him then. + +I asked nothing further of him then, and after a final, polite cough, he offered nothing more to me. After that, we passed the day in silence. \ No newline at end of file diff --git a/libs/narrator/test/units/nesting/1-1-2-1.txt b/libs/narrator/test/units/nesting/1-1-2-1.txt new file mode 100644 index 0000000..e4c6e0a --- /dev/null +++ b/libs/narrator/test/units/nesting/1-1-2-1.txt @@ -0,0 +1,28 @@ +I looked at Monsieur Fogg + +>) ... and I could contain myself no longer. +2) ... but I said nothing + +... and I could contain myself no longer. +'What is the purpose of our journey, Monsieur?' +'A wager,' he replied. + +>) 'A wager!' +2) 'Ah.' + +'A wager!' I returned. +He nodded. + +1) 'But surely that is foolishness!' +>) 'A most serious matter then!' + +'A most serious matter then!' +He nodded again. + +>) 'But can we win?' +2) 'A modest wager, I trust?' +3) I asked nothing further of him then. + +'But can we win?' +'That is what we will endeavour to find out,' he answered. +After that, we passed the day in silence. \ No newline at end of file diff --git a/libs/narrator/test/units/nesting/1-1-2-2.txt b/libs/narrator/test/units/nesting/1-1-2-2.txt new file mode 100644 index 0000000..34a9c24 --- /dev/null +++ b/libs/narrator/test/units/nesting/1-1-2-2.txt @@ -0,0 +1,28 @@ +I looked at Monsieur Fogg + +>) ... and I could contain myself no longer. +2) ... but I said nothing + +... and I could contain myself no longer. +'What is the purpose of our journey, Monsieur?' +'A wager,' he replied. + +>) 'A wager!' +2) 'Ah.' + +'A wager!' I returned. +He nodded. + +1) 'But surely that is foolishness!' +>) 'A most serious matter then!' + +'A most serious matter then!' +He nodded again. + +1) 'But can we win?' +>) 'A modest wager, I trust?' +3) I asked nothing further of him then. + +'A modest wager, I trust?' +'Twenty thousand pounds,' he replied, quite flatly. +After that, we passed the day in silence. \ No newline at end of file diff --git a/libs/narrator/test/units/nesting/1-1-2-3.txt b/libs/narrator/test/units/nesting/1-1-2-3.txt new file mode 100644 index 0000000..4db6cbb --- /dev/null +++ b/libs/narrator/test/units/nesting/1-1-2-3.txt @@ -0,0 +1,26 @@ +I looked at Monsieur Fogg + +>) ... and I could contain myself no longer. +2) ... but I said nothing + +... and I could contain myself no longer. +'What is the purpose of our journey, Monsieur?' +'A wager,' he replied. + +>) 'A wager!' +2) 'Ah.' + +'A wager!' I returned. +He nodded. + +1) 'But surely that is foolishness!' +>) 'A most serious matter then!' + +'A most serious matter then!' +He nodded again. + +1) 'But can we win?' +2) 'A modest wager, I trust?' +>) I asked nothing further of him then. + +I asked nothing further of him then, and after a final, polite cough, he offered nothing more to me. After that, we passed the day in silence. \ No newline at end of file diff --git a/libs/narrator/test/units/nesting/1-2.txt b/libs/narrator/test/units/nesting/1-2.txt new file mode 100644 index 0000000..fcd4862 --- /dev/null +++ b/libs/narrator/test/units/nesting/1-2.txt @@ -0,0 +1,14 @@ +I looked at Monsieur Fogg + +>) ... and I could contain myself no longer. +2) ... but I said nothing + +... and I could contain myself no longer. +'What is the purpose of our journey, Monsieur?' +'A wager,' he replied. + +1) 'A wager!' +>) 'Ah.' + +'Ah,' I replied, uncertain what I thought. +After that, we passed the day in silence. \ No newline at end of file diff --git a/libs/narrator/test/units/nesting/2.txt b/libs/narrator/test/units/nesting/2.txt new file mode 100644 index 0000000..4444d06 --- /dev/null +++ b/libs/narrator/test/units/nesting/2.txt @@ -0,0 +1,6 @@ +I looked at Monsieur Fogg + +1) ... and I could contain myself no longer. +>) ... but I said nothing + +... but I said nothing and we passed the day in silence. \ No newline at end of file diff --git a/libs/narrator/test/units/queries.ink b/libs/narrator/test/units/queries.ink new file mode 100644 index 0000000..45b6064 --- /dev/null +++ b/libs/narrator/test/units/queries.ink @@ -0,0 +1,13 @@ +SEED = 81 { SEED_RANDOM(81) } +RANDOM(1, 100) = { RANDOM(1, 100) } +POW(2,4) = { POW(2, 4) } +INT(1.45) = { INT(1.45) } +FLOOR(2.4) = { FLOOR(2.4) } +FLOAT(3) = { FLOAT(3) } + +* { CHOICE_COUNT() == 0 } [Choice 1] + +{ "Yes, please." == "Yes, please." : True! | False! } +{ "Yes, please." != "Yes, please." : True! | False! } +{ "Yes, please" ? "ease" : True! | False! } +{ "Yes, please" !? "ease" : True! | False! } \ No newline at end of file diff --git a/libs/narrator/test/units/queries.txt b/libs/narrator/test/units/queries.txt new file mode 100644 index 0000000..34e2c3d --- /dev/null +++ b/libs/narrator/test/units/queries.txt @@ -0,0 +1,13 @@ +SEED = 81 +RANDOM(1, 100) = 81 +POW(2,4) = 16 +INT(1.45) = 1 +FLOOR(2.4) = 2 +FLOAT(3) = 3 + +>) Choice 1 + +True! +False! +True! +False! \ No newline at end of file diff --git a/libs/narrator/test/units/stitches.ink b/libs/narrator/test/units/stitches.ink new file mode 100644 index 0000000..832e524 --- /dev/null +++ b/libs/narrator/test/units/stitches.ink @@ -0,0 +1,16 @@ +We boarded the train, but where? +* [First class] -> the_orient_express.in_first_class +* [Travel in the guard's van] + -> the_orient_express.in_the_guards_van + +=== the_orient_express === += in_first_class + I settled my master. + * [Move to third class] + -> in_third_class + += in_third_class + I put myself in third. + += in_the_guards_van + Whoosh.. \ No newline at end of file diff --git a/libs/narrator/test/units/stitches/1-1.txt b/libs/narrator/test/units/stitches/1-1.txt new file mode 100644 index 0000000..74f5214 --- /dev/null +++ b/libs/narrator/test/units/stitches/1-1.txt @@ -0,0 +1,10 @@ +We boarded the train, but where? + +>) First class +2) Travel in the guard's van + +I settled my master. + +>) Move to third class + +I put myself in third. \ No newline at end of file diff --git a/libs/narrator/test/units/stitches/2.txt b/libs/narrator/test/units/stitches/2.txt new file mode 100644 index 0000000..860a0f0 --- /dev/null +++ b/libs/narrator/test/units/stitches/2.txt @@ -0,0 +1,6 @@ +We boarded the train, but where? + +1) First class +>) Travel in the guard's van + +Whoosh.. \ No newline at end of file diff --git a/libs/narrator/test/units/text-glue.ink b/libs/narrator/test/units/text-glue.ink new file mode 100644 index 0000000..549fb1b --- /dev/null +++ b/libs/narrator/test/units/text-glue.ink @@ -0,0 +1,9 @@ +We hurried home <> +-> to_savile_row + +=== to_savile_row === +to Savile Row +-> as_fast_as_we_could + +=== as_fast_as_we_could === +<> as fast as we could. \ No newline at end of file diff --git a/libs/narrator/test/units/text-glue.txt b/libs/narrator/test/units/text-glue.txt new file mode 100644 index 0000000..07226ae --- /dev/null +++ b/libs/narrator/test/units/text-glue.txt @@ -0,0 +1 @@ +We hurried home to Savile Row as fast as we could. \ No newline at end of file diff --git a/libs/narrator/test/units/text-line.ink b/libs/narrator/test/units/text-line.ink new file mode 100644 index 0000000..db86949 --- /dev/null +++ b/libs/narrator/test/units/text-line.ink @@ -0,0 +1 @@ + Hello, world! I am a string. \ No newline at end of file diff --git a/libs/narrator/test/units/text-line.txt b/libs/narrator/test/units/text-line.txt new file mode 100644 index 0000000..b106506 --- /dev/null +++ b/libs/narrator/test/units/text-line.txt @@ -0,0 +1 @@ +Hello, world! I am a string. \ No newline at end of file diff --git a/libs/narrator/test/units/text-lines.ink b/libs/narrator/test/units/text-lines.ink new file mode 100644 index 0000000..c52d935 --- /dev/null +++ b/libs/narrator/test/units/text-lines.ink @@ -0,0 +1,3 @@ +Hello, world! +Hello? +Hello, are you there? \ No newline at end of file diff --git a/libs/narrator/test/units/text-lines.txt b/libs/narrator/test/units/text-lines.txt new file mode 100644 index 0000000..c52d935 --- /dev/null +++ b/libs/narrator/test/units/text-lines.txt @@ -0,0 +1,3 @@ +Hello, world! +Hello? +Hello, are you there? \ No newline at end of file diff --git a/libs/narrator/test/units/text-tags.ink b/libs/narrator/test/units/text-tags.ink new file mode 100644 index 0000000..a1baa39 --- /dev/null +++ b/libs/narrator/test/units/text-tags.ink @@ -0,0 +1,4 @@ +# globalTag1 +# globalTag2 #globalTag3 +A line of normal game-text. # colour it blue +A line of normal game-text. #tag1 #tag two # tag 3 \ No newline at end of file diff --git a/libs/narrator/test/units/text-tags.txt b/libs/narrator/test/units/text-tags.txt new file mode 100644 index 0000000..d7fbbcd --- /dev/null +++ b/libs/narrator/test/units/text-tags.txt @@ -0,0 +1,2 @@ +A line of normal game-text. #globalTag1 #globalTag2 #globalTag3 #colour it blue +A line of normal game-text. #tag1 #tag two #tag 3 \ No newline at end of file diff --git a/libs/narrator/test/units/tunnels.ink b/libs/narrator/test/units/tunnels.ink new file mode 100644 index 0000000..8b9bd6f --- /dev/null +++ b/libs/narrator/test/units/tunnels.ink @@ -0,0 +1,29 @@ +The dark grass is soft under your feet. +{1: + -> sleep_here -> wake_here -> + +-else: + wtf +} + +->move_on-> + +->end +- (end) + +-> END + +=== wake_here === +You wake as the sun rises. +->-> + +=== sleep_here === +You lie down and try to close your eyes. +-> dream + +=== dream === +You dream about the dream. +->-> + +=== move_on +It is time to move on. ->-> \ No newline at end of file diff --git a/libs/narrator/test/units/tunnels.txt b/libs/narrator/test/units/tunnels.txt new file mode 100644 index 0000000..14ec736 --- /dev/null +++ b/libs/narrator/test/units/tunnels.txt @@ -0,0 +1,5 @@ +The dark grass is soft under your feet. +You lie down and try to close your eyes. +You dream about the dream. +You wake as the sun rises. +It is time to move on. \ No newline at end of file diff --git a/libs/narrator/test/units/vars.ink b/libs/narrator/test/units/vars.ink new file mode 100644 index 0000000..bb93467 --- /dev/null +++ b/libs/narrator/test/units/vars.ink @@ -0,0 +1,18 @@ +VAR money = 100 +VAR has_knife = true +VAR name = "Katy" + +{ money } +~ money++ +{ money } +~ money += 100 +{ money } +~ money -= 50 +{ money } +~ money-- +{ money } +~ money = 500 +~ temp coins = 20 + +Hello, { name }! Do you have { money } bucks? { coins } pennies may be? +{ has_knife: Nope| Yeap}. \ No newline at end of file diff --git a/libs/narrator/test/units/vars.txt b/libs/narrator/test/units/vars.txt new file mode 100644 index 0000000..68c5926 --- /dev/null +++ b/libs/narrator/test/units/vars.txt @@ -0,0 +1,7 @@ +100 +101 +201 +151 +150 +Hello, Katy! Do you have 500 bucks? 20 pennies may be? +Nope. \ No newline at end of file diff --git a/libs/sfxrlua b/libs/sfxrlua deleted file mode 160000 index 949429d..0000000 --- a/libs/sfxrlua +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 949429d4618aee8b019899af101bd5f793d07a64 diff --git a/libs/sfxrlua/.gitignore b/libs/sfxrlua/.gitignore new file mode 100644 index 0000000..1809889 --- /dev/null +++ b/libs/sfxrlua/.gitignore @@ -0,0 +1,40 @@ +# Compiled Lua sources +luac.out + +# luarocks build files +*.src.rock +*.zip +*.tar.gz + +# Object files +*.o +*.os +*.ko +*.obj +*.elf + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo +*.def +*.exp + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex diff --git a/libs/sfxrlua/.gitmodules b/libs/sfxrlua/.gitmodules new file mode 100644 index 0000000..e4ad5eb --- /dev/null +++ b/libs/sfxrlua/.gitmodules @@ -0,0 +1,3 @@ +[submodule "loveframes"] + path = demo/loveframes + url = https://github.com/NikolaiResokav/LoveFrames diff --git a/libs/sfxrlua/LICENSE b/libs/sfxrlua/LICENSE new file mode 100644 index 0000000..b86468e --- /dev/null +++ b/libs/sfxrlua/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 nucular + +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. \ No newline at end of file diff --git a/libs/sfxrlua/README.md b/libs/sfxrlua/README.md new file mode 100644 index 0000000..d334a2f --- /dev/null +++ b/libs/sfxrlua/README.md @@ -0,0 +1,61 @@ +sfxr.lua +======== + +``` +luarocks install sfxr +``` + +A port of the sfxr sound effect synthesizer to pure Lua, designed to be used +together with the *awesome* [LÖVE](https://love2d.org) game framework. + +Demo +---- + +To run the demo application you first need to download +[LoveFrames](https://github.com/NikolaiResokav/LoveFrames) as a submodule: +``` +git submodule update --init +love demo +``` +Note: Due to LoveFrames only supporting LÖVE 0.9.x, this dependency is inherited +by the demo. A move to a new GUI framework is pending. + +Example usage +------------- + +The following code examples demonstrate how to play a randomly generated sound: + +With [LÖVE](http://love2d.org): +```lua +local sfxr = require("sfxr") + +function love.load() + local sound = sfxr.newSound() + sound:randomize() + local sounddata = sound:generateSoundData() + local source = love.audio.newSource(sounddata) + source:play() +end +``` + +With [lao](https://github.com/TheLinx/lao): +```lua +require("ao") +local sfxr = require("sfxr") + +local driverId = ao.defaultDriverId() +local device = ao.openLive(driverId, {bits = 16, rate = 44100, channels = 1}) + +local sound = sfxr.newSound() +sound:randomize() + +device:play(sound:generateString()) +``` + +Documentation +------------- + +[**The latest documentation build is available here**](http://nucular.github.io/sfxrlua/) + +This project uses [LDoc](http://stevedonovan.github.io/ldoc/) for autogenerated +API documentation. diff --git a/libs/sfxrlua/config.ld b/libs/sfxrlua/config.ld new file mode 100644 index 0000000..afd09fc --- /dev/null +++ b/libs/sfxrlua/config.ld @@ -0,0 +1,12 @@ +project = "sfxr.lua" +description = "A port of the sfxr sound effect synthesizer to Lua" +title = "sfxr.lua Documentation" +format = "markdown" +not_luadoc = true +style = "!pale" +kind_names = {topic = "Topics", module = "API"} +dir = "./docs" + +file = "sfxr.lua" +topics = { +} diff --git a/libs/sfxrlua/demo/conf.lua b/libs/sfxrlua/demo/conf.lua new file mode 100644 index 0000000..c94e801 --- /dev/null +++ b/libs/sfxrlua/demo/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.version = love._version:match("0%.9%.%d+") or "0.9.x" + t.window.width = 640 + t.window.height = 600 + t.window.title = "sfxr.lua Demo" +end diff --git a/libs/sfxrlua/demo/main.lua b/libs/sfxrlua/demo/main.lua new file mode 100644 index 0000000..7136455 --- /dev/null +++ b/libs/sfxrlua/demo/main.lua @@ -0,0 +1,695 @@ +#/usr/bin/env love +-- love . + +local sfxr = require("sfxr") + +-- Global stuff +local source +local sound +local sounddata + +local seed +local playbutton +local playing = false + +local wavecanvas +local statistics = { + generation = 0, + transfer = 0, + waveview = 0, + duration = 0 +} + +-- This will hold all sliders and the wave form box +local guiparams = {} +-- The parameter list is built from this +-- {{"Text", "table name", {{"Text", "table paramter", min, max}, ...}}, ...} +local guicategories = { + { + "Envelope", + "envelope", + { + {"Attack Time", "attack", 0, 1}, + {"Sustain Time", "sustain", 0, 1}, + {"Sustain Punch", "punch", 0, 1}, + {"Decay Time", "decay", 0, 1} + } + }, + { + "Frequency", + "frequency", + { + {"Start", "start", 0, 1}, + {"Minimum", "min", 0, 1}, + {"Slide", "slide", -1, 1}, + {"Delta Slide", "dslide", -1, 1} + } + }, + { + "Vibrato", + "vibrato", + { + {"Depth", "depth", 0, 1}, + {"Speed", "speed", 0, 1} + } + }, + { + "Change", + "change", + { + {"Amount", "amount", -1, 1}, + {"Speed", "speed", 0, 1} + } + }, + { + "Square Duty", + "duty", + { + {"Ratio", "ratio", 0, 1}, + {"Sweep", "sweep", -1, 1} + } + }, + { + "Phaser", + "phaser", + { + {"Offset", "offset", -1, 1}, + {"Sweep", "sweep", -1, 1} + } + }, + { + "Low Pass", + "lowpass", + { + {"Cutoff", "cutoff", 0, 1}, + {"Sweep", "sweep", -1, 1}, + {"Resonance", "resonance", 0, 1} + } + }, + { + "High Pass", + "highpass", + { + {"Cutoff", "cutoff", 0, 1}, + {"Sweep", "sweep", -1, 1} + } + } +} + +-- Easy lookup of wave forms +local waveFormList = { + ["Square"] = 0, + ["Sawtooth"] = 1, + ["Sine"] = 2, + ["Noise"] = 3, + [0] = "Square", + [1] = "Sawtooth", + [2] = "Sine", + [3] = "Noise" +} + + +function stopSound() + playbutton:SetText("Play") + if source then + source:stop() + end +end + +function playSound() + -- Stop the currently playing source + if source then + source:stop() + end + + local t = love.timer.getTime() + local tab = sound:generateTable(sfxr.FREQ_44100, sfxr.BITS_FLOAT) + t = love.timer.getTime() - t + statistics.generation = math.floor(t * 10000) / 10 + + if #tab == 0 then + return nil + end + + sounddata = love.sound.newSoundData(#tab, 44100, 16, 1) + statistics.duration = math.floor(sounddata:getDuration() * 10000) / 10 + + -- Stuff for the wave view + local waveview = {} + local j = 0 + local max = -1 + local min = 1 + local avg = 0.5 + + local t = love.timer.getTime() + for i = 0, #tab - 1 do + + local v = tab[i + 1] + -- Copy the sample over to the SoundData + sounddata:setSample(i, v) + + -- Add the minimal and maximal sample to the wave view + -- every 256 samples. This is how Audacity does it, actually. + j = j + 1 + min = math.min(v, min) + max = math.max(v, max) + if j >= 256 then + waveview[#waveview + 1] = min + waveview[#waveview + 1] = max + j = 0 + min, max = 1, -1 + end + end + t = love.timer.getTime() - t + statistics.transfer = math.floor(t * 10000) / 10 + + updateWaveCanvas(waveview) + updateStatistics() + + if sounddata then + source = love.audio.newSource(sounddata) + source:play() + playbutton:SetText("Stop Playing") + playing = true + end +end + +function createSeedBox() + local f = lf.Create("form"):SetName("Random Seed") + + seed = lf.Create("numberbox") + :SetValue(math.floor(love.timer.getTime())) + :SetMax(math.huge) + :SetMin(-math.huge) + :SetWidth(100) + + f:AddItem(seed):SetPos(5, 240) +end + +function createPresetGenerators() + local f = lf.Create("form") + :SetName("Preset Generators") + local generators = { + {"Pickup/Coin", sound.randomPickup}, + {"Laser/Shoot", sound.randomLaser}, + {"Explosion", sound.randomExplosion}, + {"Powerup", sound.randomPowerup}, + {"Hit/Hurt", sound.randomHit}, + {"Jump", sound.randomJump}, + {"Blip/Select", sound.randomBlip} + } + + for i, v in ipairs(generators) do + local b = lf.Create("button") + :SetText(v[1]) + :SetWidth(100) + f:AddItem(b) + + b.OnClick = function(self) + v[2](sound, seed:GetValue()) + seed:SetValue(seed:GetValue() + 1) + updateParameters() + playSound() + end + end + + f:SetPos(5, 5):SetWidth(110) +end + +function createRandomizers() + local f = lf.Create("form"):SetName("Randomizers") + + local b = lf.Create("button") + :SetText("Mutate") + :SetWidth(100) + f:AddItem(b) + + b.OnClick = function(self) + sound:mutate() + updateParameters() + playSound() + end + + local b = lf.Create("button") + :SetText("Randomize") + :SetWidth(100) + f:AddItem(b) + + b.OnClick = function(self) + sound:randomize(seed:GetValue()) + updateParameters() + seed:SetValue(seed:GetValue() + 1) + playSound() + end + + f:SetPos(5, 515):SetSize(110, 80) +end + +function createParameters() + local f = lf.Create("form"):SetName("Parameters") + + local l = lf.Create("list") + :SetSpacing(5) + :SetPadding(5) + :SetSize(340, 565) + f:AddItem(l) + + + -- Waveforms + l:AddItem(lf.Create("text"):SetPos(0, pheight):SetText("Wave Form")) + + local m = lf.Create("multichoice") + for i = 0, #waveFormList do + m:AddChoice(waveFormList[i]) + end + m:SetChoice("Square") + + m.OnChoiceSelected = function(o, c) + sound.wavetype = waveFormList[c] + end + + l:AddItem(m) + guiparams.waveform = m + + + -- Repeat speed + local t = lf.Create("text") + :SetText("Repeat Speed 0") + :SetPos(0, pheight) + + local s = lf.Create("slider") + :SetWidth(120) + :SetMinMax(0, 1) + :SetValue(sound.repeatspeed) + + s.OnValueChanged = function(o) + local v = o:GetValue() + if v <= 0.02 and v >= -0.02 and v ~= 0 then + o:SetValue(0) + sound.repeatspeed = 0 + t:SetText("Repeat Speed 0") + else + sound.repeatspeed = v + t:SetText("Repeat Speed " .. tostring(math.floor(v * 100) / 100)) + end + end + + l:AddItem(t):AddItem(s) + guiparams.repeatspeed = {s, t} + + + for i1, v1 in ipairs(guicategories) do + local c = lf.Create("collapsiblecategory"):SetText(v1[1]) + l:AddItem(c) + + local p = lf.Create("panel") + local pheight = 0 + p.Draw = function() end + c:SetObject(p) + + guiparams[v1[2]] = {} + + for i2, v2 in ipairs(v1[3]) do + lf.Create("text", p) + :SetText(v2[1]) + :SetPos(0, pheight) + + local t = lf.Create("text", p) + :SetText("0") + :SetPos(95, pheight) + + local s = lf.Create("slider", p) + :SetPos(130, pheight - 3) + :SetWidth(170) + :SetMinMax(v2[3], v2[4]) + :SetValue(sound[v1[2]][v2[2]]) + + s.OnValueChanged = function(o) + local v = o:GetValue() + if v <= 0.02 and v >= -0.02 and v ~= 0 then + o:SetValue(0) + sound[v1[2]][v2[2]] = 0 + t:SetText("0") + else + sound[v1[2]][v2[2]] = v + t:SetText(math.floor(v * 100) / 100) + end + end + + guiparams[v1[2]][v2[2]] = {s, t} + pheight = pheight + 30 + end + + p:SetHeight(pheight - 10) + end + + + f:SetPos(125, 5):SetSize(350, 590) +end + +function createActionButtons() + local f = lf.Create("form"):SetName("Actions") + + local b = lf.Create("button") + :SetText("Generate and Play") + :SetWidth(140) + + b.OnClick = function(o) + if not playing then + playSound() + else + stopSound() + end + end + + playbutton = b + f:AddItem(b) + + + local fr = lf.Create("frame") + :SetName("File Picker") + :SetSize(400, 300) + :Center() + :SetVisible(false) + :SetModal(false) + + local frl = lf.Create("columnlist", fr) + :SetPos(5, 30) + :SetSize(390, 235) + :AddColumn("Name") + + local frt = lf.Create("textinput", fr) + :SetPos(5, 270) + :SetWidth(300) + + local frb = lf.Create("button", fr) + :SetPos(315, 270) + + frl.OnRowSelected = function(p, row, data) + frt:SetText(data[1]) + end + + fr.OnClose = function(o) + frl:Clear() + fr:SetVisible(false):SetModal(false) + return false + end + + local function saveHandler(type, cb) + return function() + fr:SetName("Save to ." .. type) + frt:SetText("sound." .. type) + frb:SetText("Save") + + love.filesystem.getDirectoryItems("sounds", function(name) + if name:find(type, #type-#name+1, true) then + frl:AddRow(name) + end + end) + + frb.OnClick = function(o) + local name = frt:GetText() + if (#name > 0) then + local f = love.filesystem.newFile("sounds/" .. name, "w") + if f then + cb(f) + frl:Clear() + fr:SetVisible(false):SetModal(false) + end + end + end + + frt.OnEnter = frb.OnClick + fr:SetVisible(true) + :SetModal(true) + :Center() + end + end + + local function loadHandler(type, cb) + return function() + fr:SetName("Load from ." .. type) + frt:SetText("sound." .. type) + frb:SetText("Load") + + love.filesystem.getDirectoryItems("sounds", function(name) + if name:find(type, #type-#name+1, true) then + frl:AddRow(name) + end + end) + + frb.OnClick = function(o) + local name = frt:GetText() + if (#name > 0) then + local f = love.filesystem.newFile("sounds/" .. name, "r") + if f then + cb(f) + frl:Clear() + fr:SetVisible(false):SetModal(false) + end + end + end + + frt.OnEnter = frb.OnClick + fr:SetVisible(true) + :SetModal(true) + :Center() + end + end + + + local sb = lf.Create("button") + :SetText("Save Lua") + :SetWidth(67) + sb.OnClick = saveHandler("lua", function(f) sound:save(f, true) end) + f:AddItem(sb) + + local lb = lf.Create("button") + :SetText("Load Lua") + :SetWidth(67) + lb.OnClick = loadHandler("lua", function(f) sound:load(f) end) + f:AddItem(lb) + + local bsb = lf.Create("button") + :SetText("Save binary") + :SetWidth(67) + bsb.OnClick = saveHandler("sfs", function(f) sound:saveBinary(f) end) + f:AddItem(bsb) + + local blb = lf.Create("button") + :SetText("Load binary") + :SetWidth(67) + blb.OnClick = loadHandler("sfs", function(f) sound:loadBinary(f) end) + f:AddItem(blb) + + local eb = lf.Create("button") + :SetText("Export WAV") + :SetWidth(140) + eb.OnClick = saveHandler("wav", function(f) sound:exportWAV(f) end) + f:AddItem(eb) + + f:SetPos(485, 455):SetSize(150, 140) + + lb:SetPos(78, 47) + bsb:SetY(77) + blb:SetPos(78, 77) + eb:SetY(107) +end + +function createOther() + local f = lf.Create("form") + :SetName("Wave View") + :SetPos(485, 5) + :SetSize(150, 170) + + local draw = function(o) + if source then + love.graphics.setColor(255, 255, 255) + love.graphics.draw(wavecanvas, 495, 25) + + -- Draw a fancy position cursor + local pos = source:tell("samples") + local max = sounddata:getSampleCount() + local x = 495 + (pos / max) * 125 + love.graphics.setColor(255, 153, 0) + love.graphics.line(x, 25, x, 165) + end + + lf.skins.available["Orange"].DrawForm(o) + end + f.Draw = draw + + + local f = lf.Create("form"):SetName("Volume") + + local t = lf.Create("text"):SetText("Master 0.5") + f:AddItem(t) + + + local s = lf.Create("slider") + :SetMinMax(0, 1) + :SetSize(135, 20) + + s.OnValueChanged = function(o) + local v = o:GetValue() + if v <= 0.52 and v >= 0.48 and v ~= 0.5 then + o:SetValue(0.5) + v = 0.5 + end + sound.volume.master = v + t:SetText("Master " .. tostring(math.floor(v * 100) / 100)) + end + + s:SetValue(sound.volume.master) + f:AddItem(s) + + + local t = lf.Create("text"):SetText("Sound 0.5") + f:AddItem(t) + + local s = lf.Create("slider") + :SetMinMax(0, 1) + :SetSize(135, 20) + + s.OnValueChanged = function(o) + local v = o:GetValue() + if v <= 0.52 and v >= 0.48 and v ~= 0.5 then + o:SetValue(0.5) + v = 0.5 + end + sound.volume.sound = v + t:SetText("Sound " .. tostring(math.floor(v * 100) / 100)) + end + + s:SetValue(sound.volume.sound) + f:AddItem(s) + + + f:SetPos(485, 340):SetWidth(150) + + + local f = lf.Create("form"):SetName("Times / Duration") + + local t = lf.Create("text"):SetText("Generation: 0ms") + f:AddItem(t) + statistics.generationtext = t + + local t = lf.Create("text"):SetText("Transfer: 0ms") + f:AddItem(t) + statistics.transfertext = t + + local t = lf.Create("text"):SetText("Wave View: 0ms") + f:AddItem(t) + statistics.waveviewtext = t + + local t = lf.Create("text"):SetText("Duration: 0ms") + f:AddItem(t) + statistics.durationtext = t + + f:SetPos(485, 185):SetWidth(150) +end + +function updateParameters() + -- Iterate through the list of parameters and update all of them + for i1, v1 in ipairs(guicategories) do + for i2, v2 in ipairs(v1[3]) do + local v = sound[v1[2]][v2[2]] + local s, t = unpack(guiparams[v1[2]][v2[2]]) + s:SetValue(v) + t:SetText(math.floor(v * 100) / 100) + end + end + + local s, t = unpack(guiparams.repeatspeed) + local v = sound.repeatspeed + + s:SetValue(v) + t:SetText("Repeat Speed " .. tostring(math.floor(v * 100) / 100)) + + guiparams.waveform:SetChoice(waveFormList[sound.wavetype]) +end + +function updateWaveCanvas(waveview) + local t = love.timer.getTime() + wavecanvas:clear() + love.graphics.setCanvas(wavecanvas) + love.graphics.setColor(255, 255, 255) + love.graphics.setLineStyle("rough") + + -- Iterate through the passed table and draw all lines to the canvas + local step = 125 / #waveview + local last = 70 + for i, v in ipairs(waveview) do + local x = (i * step) + local y = (-v + 1) * 70 + + love.graphics.line(x - step, last, x, y) + last = y + end + + -- Draw the zero line + love.graphics.setColor(255, 80, 51, 200) + love.graphics.line(0, 70, 125, 70) + + love.graphics.setCanvas() + t = love.timer.getTime() - t + statistics.waveview = math.floor(t * 10000) / 10 +end + +function updateStatistics() + statistics.durationtext:SetText("Duration: " .. statistics.duration .. " ms") + statistics.transfertext:SetText("Transfer: " .. statistics.transfer .. " ms") + statistics.waveviewtext:SetText("Wave View: " .. statistics.waveview .. " ms") + statistics.generationtext:SetText("Generation: " .. statistics.generation .. " ms") +end + +function love.load() + require("loveframes") + lf = loveframes + lf.util.SetActiveSkin("Orange") + + love.graphics.setBackgroundColor(200, 200, 200) + + if not love.filesystem.isDirectory("sounds") then + love.filesystem.createDirectory("sounds") + end + + sound = sfxr.newSound() + + createSeedBox() + createPresetGenerators() + createRandomizers() + createParameters() + createActionButtons() + createOther() + + wavecanvas = love.graphics.newCanvas(125, 140) + + love.mousepressed = lf.mousepressed + love.mousereleased = lf.mousereleased + love.keyreleased = lf.keyreleased + love.textinput = lf.textinput +end + +function love.update(dt) + lf.update(dt) + if source then + if playing and not source:isPlaying() then + playing = false + playbutton:SetText("Generate and Play") + end + end +end + +function love.draw() + lf.draw() +end + +function love.keypressed(key) + if key == " " then + playSound() + elseif key == "escape" then + love.event.push("quit") + end + lf.keypressed(key) +end diff --git a/libs/sfxrlua/docs/api/sfxr.html b/libs/sfxrlua/docs/api/sfxr.html new file mode 100644 index 0000000..75c9a9e --- /dev/null +++ b/libs/sfxrlua/docs/api/sfxr.html @@ -0,0 +1,1799 @@ + + + + + sfxr.lua Documentation + + + + +
+ +
+ +
+
+
+ + +
+ + + + + + +
+ +

Module sfxr

+

A port of the sfxr sound effect synthesizer to pure Lua, designed to be used +together with the awesome LÖVE game framework.

+

+ +

+ + +

Functions

+ + + + + +
newSound (...)Construct and return a new Sound instance.
+

Constants

+ + + + + + + + + + + + + + + + + + + + + +
VERSIONThe module version (SemVer format)
WAVEFORMWaveform constants
SAMPLERATESampling rate constants + (use the number values directly, these are just for lookup)
BITDEPTHBit depth constants + (use the number values directly, these are just for lookup)
ENDIANNESSEndianness constants
+

Class Sound

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sfxr.Sound:__init ()Initialize the Sound instance.
self.vibratoA vibrato-like amplitude + modulation effect + SerializationVibrato
sfxr.Sound:resetParameters ()Set all parameters to their default values.
sfxr.Sound:sanitizeParameters ()Clamp all parameters within their sane ranges.
sfxr.Sound:generate ([rate=44100[, depth=0]])Generate the sound and yield the sample data.
sfxr.Sound:getEnvelopeLimit ([rate=44100])Get the maximum sample limit allowed by the current envelope.
sfxr.Sound:generateTable ([rate=44100[, depth=0[, tab]]])Generate the sound into a table.
sfxr.Sound:generateString ([rate=44100[, depth=16[, endianness=0]]])Generate the sound to a binary string.
sfxr.Sound:generateSoundData ([rate=44100[, depth=0[, sounddata]]])Synthesize the sound to a LÖVE SoundData instance.
+

Parameters

+ + + + + + + + + + + + + +
self.supersamplingNumber of supersampling passes to perform (default 8)
self.repeatspeedRepeat speed: + Times to repeat the frequency slide over the course of the envelope + (default 0.0, min 0.0, max 1.0)
self.waveformThe base waveform (default SQUARE)
+

Volume

+ + + + + + + + + + + + + +
self.volumeThe sound volume and gain all samples are multiplied with
self.volume.masterMaster volume (default 0.5)
self.volume.soundAdditional gain (default 0.5)
+

Envelope

+ + + + + + + + + + + + + + + + + + + + + +
self.envelopeThe ASD envelope that controls the sound + amplitude (volume) over time
self.envelope.attackAttack time: + Time the sound takes to reach its peak amplitude + (default 0.0, min 0.0, max 1.0)
self.envelope.sustainSustain time: + Time the sound stays on its peak amplitude + (default 0.0, min 0.0, max 1.0)
self.envelope.punchSustain punch: + Amount by which the sound peak amplitude is increased at the start of the + sustain time + (default 0.0, min 0.0, max 1.0)
self.envelope.decayDecay time: + Time the sound takes to decay after its sustain time + (default 0.0, min 0.0, max 1.0)
+

Frequency

+ + + + + + + + + + + + + + + + + + + + + +
self.frequencyThe base and minimum frequencies of the tone generator and their + slides
self.frequency.startStart frequency: + Base tone of the sound, before sliding + (default 0.0, min 0.0, max 1.0)
self.frequency.minMin frequency: + Tone below which the sound will get cut off + (default 0.0, min 0.0, max 1.0)
self.frequency.slideSlide: + Amount by which the frequency is increased or decreased over time + (default 0.0, min -1.0, max 1.0)
self.frequency.dslideDelta slide: + Amount by which the slide is increased or decreased over time + (default 0.0, min -1.0, max 1.0)
+

Change

+ + + + + + + + + + + + + +
self.changeChanges the frequency mid-sound to create a characteristic + "coin"-effect
self.change.amountChange amount: + Amount by which the frequency is changed mid-sound + (default 0.0, min -1.0, max 1.0)
self.change.speedChange speed: + Time before the frequency change happens + (default 0.0, min 0.0, max 1.0)
+

Duty

+ + + + + + + + + + + + + +
self.dutyThe duty of the square + waveform
self.duty.ratioSquare duty: + Width of the square wave pulse cycle (doesn't affect other waveforms) + (default 0.0, min 0.0, max 1.0)
self.duty.sweepDuty sweep: + Amount by which the square duty is increased or decreased over time + (default 0.0, min -1.0, max 1.0)
+

Phaser

+ + + + + + + + + + + + + +
self.phaserA simple phaser + effect
self.phaser.offsetPhaser offset: + Amount by which the phaser signal is offset from the sound + (default 0.0, min -1.0, max 1.0)
self.phaser.sweepPhaser sweep: + Amount by which the phaser offset is increased or decreased over time + (default 0.0, min -1.0, max 1.0)
+

Lowpass

+ + + + + + + + + + + + + + + + + +
self.lowpassA lowpass filter + effect
self.lowpass.cutoffLowpass filter cutoff: + Lower bound for frequencies allowed to pass through this filter + (default 0.0, min 0.0, max 1.0)
self.lowpass.sweepLowpass filter cutoff sweep: + Amount by which the LP filter cutoff is increased or decreased + over time + (default 0.0, min -1.0, max 1.0)
self.lowpass.resonanceLowpass filter resonance: + Amount by which certain resonant frequencies near the cutoff are + increased + (default 0.0, min 0.0, max 1.0)
+

Highpass

+ + + + + + + + + + + + + +
self.highpassA highpass filter + effect
self.highpass.cutoffHighpass filter cutoff: + Upper bound for frequencies allowed to pass through this filter + (default 0.0, min 0.0, max 1.0)
self.highpass.sweepHighpass filter cutoff sweep: + Amount by which the HP filter cutoff is increased or decreased + over time + (default 0.0, min -1.0, max 1.0)
+

Vibrato

+ + + + + + + + + + + + + +
self.vibrato.depthVibrato depth: + Amount of amplitude modulation + (default 0.0, min 0.0, max 1.0)
self.vibrato.speedVibrato speed: + Oscillation speed of the vibrato + (default 0.0, min 0.0, max 1.0)
self.vibrato.delayVibrato delay: + Unused and unimplemented + (default 0.0, min 0.0, max 1.0)
+

Randomization

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sfxr.Sound:randomize ([seed])Randomize all sound parameters
sfxr.Sound:mutate ([amount=1][, seed], changefreq)Mutate all sound parameters
sfxr.Sound:randomPickup ([seed])Randomize all sound parameters to generate a "pick up" sound
sfxr.Sound:randomLaser ([seed])Randomize all sound parameters to generate a laser sound
sfxr.Sound:randomExplosion ([seed])Randomize all sound parameters to generate an explosion sound
sfxr.Sound:randomPowerup ([seed])Randomize all sound parameters to generate a "power up" sound
sfxr.Sound:randomHit ([seed])Randomize all sound parameters to generate a hit sound
sfxr.Sound:randomJump ([seed])Randomize all sound parameters to generate a jump sound
sfxr.Sound:randomBlip ([seed])Randomize all sound parameters to generate a "blip" sound
+

Serialization

+ + + + + + + + + + + + + + + + + + + + + +
sfxr.Sound:exportWAV (f[, rate=44100[, depth=0]])Generate and export the audio data to a PCM WAVE file.
sfxr.Sound:save (f[, minify=true])Save the sound parameters to a file as a Lua table
sfxr.Sound:load (f)Load the sound parameters from a file containing a Lua table
sfxr.Sound:saveBinary (f)Save the sound parameters to a file in the sfxr binary format (version 102)
sfxr.Sound:loadBinary (f)Load the sound parameters from a file in the sfxr binary format + (version 100-102)
+ +
+
+ + +

Functions

+ +
+
+ + newSound (...) +
+
+ Construct and return a new Sound instance. + + +

Parameters:

+
    +
  • ... + + + +
  • +
+ +

Returns:

+
    + + Sound + a Sound instance +
+ + + + +
+
+

Constants

+ +
+
+ + VERSION +
+
+ The module version (SemVer format) + + + + + + + +
+
+ + WAVEFORM +
+
+ Waveform constants + + +

Fields:

+ + + + + + +
+
+ + SAMPLERATE +
+
+ Sampling rate constants + (use the number values directly, these are just for lookup) + + +

Fields:

+
    +
  • 22050 + 22.05 kHz (= 22050) +
  • +
  • 44100 + 44.1 kHz (= 44100) +
  • +
+ + + + + +
+
+ + BITDEPTH +
+
+ Bit depth constants + (use the number values directly, these are just for lookup) + + +

Fields:

+
    +
  • 0 + floating point bit depth, -1 to 1 (= 0) +
  • +
  • 8 + unsigned 8 bit, 0x00 to 0xFF (= 8) +
  • +
  • 16 + unsigned 16 bit, 0x0000 to 0xFFFF (= 16) +
  • +
+ + + + + +
+
+ + ENDIANNESS +
+
+ Endianness constants + + +

Fields:

+
    +
  • LITTLE + little endian (= 0) +
  • +
  • BIG + big endian (= 1) +
  • +
+ + + + + +
+
+

Class Sound

+ +
+ The main Sound class. +
+
+
+ + sfxr.Sound:__init () +
+
+ Initialize the Sound instance. + Called by the constructor. + + + + + + + +
+
+ + self.vibrato +
+
+ A vibrato-like amplitude + modulation effect + SerializationVibrato + + + + + + + +
+
+ + sfxr.Sound:resetParameters () +
+
+ Set all parameters to their default values. Does not affect + supersampling and volume. + Called by the initializer. + + + + + + + +
+
+ + sfxr.Sound:sanitizeParameters () +
+
+ Clamp all parameters within their sane ranges. + + + + + + + +
+
+ + sfxr.Sound:generate ([rate=44100[, depth=0]]) +
+
+ Generate the sound and yield the sample data. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth + (default 0) +
  • +
+ +

Returns:

+
    + + function() + a generator that yields the next sample when called +
+ +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + +

Usage:

+
    +
     for s in sound:generate(44100, 0) do
    +   -- do something with s
    + end
    +
+ +
+
+ + sfxr.Sound:getEnvelopeLimit ([rate=44100]) +
+
+ Get the maximum sample limit allowed by the current envelope. + Does not take any other limits into account, so the returned count might be + higher than samples actually generated. Still useful though. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
+ + +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + + +
+
+ + sfxr.Sound:generateTable ([rate=44100[, depth=0[, tab]]]) +
+
+ Generate the sound into a table. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth + (default 0) +
  • +
  • tab + {} + the table to synthesize into + (optional) +
  • +
+ +

Returns:

+
    +
  1. + {number,...} + the table filled with sample data
  2. +
  3. + int + the number of written samples (== #tab)
  4. +
+ +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + + +
+
+ + sfxr.Sound:generateString ([rate=44100[, depth=16[, endianness=0]]]) +
+
+ Generate the sound to a binary string. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth (may not be 0) + (default 16) +
  • +
  • endianness + ENDIANNESS + the endianness (ignored when depth == 8) + (default 0) +
  • +
+ +

Returns:

+
    +
  1. + string + a binary string of sample data
  2. +
  3. + int + the number of written samples
  4. +
+ +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x", "invalid endianness: x" + + + +
+
+ + sfxr.Sound:generateSoundData ([rate=44100[, depth=0[, sounddata]]]) +
+
+ Synthesize the sound to a LÖVE SoundData instance. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth + (default 0) +
  • +
  • sounddata + love.sound.SoundData + a SoundData instance (will be + created if not passed) + (optional) +
  • +
+ +

Returns:

+
    +
  1. + love.sound.SoundData + a SoundData instance
  2. +
  3. + int + the number of written samples
  4. +
+ +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + + +
+
+

Parameters

+ +
+
+ + self.supersampling +
+
+ Number of supersampling passes to perform (default 8) + + + + + + + +
+
+ + self.repeatspeed +
+
+ Repeat speed: + Times to repeat the frequency slide over the course of the envelope + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.waveform +
+
+ The base waveform (default SQUARE) + + + + + + + +
+
+

Volume

+ +
+
+ + self.volume +
+
+ The sound volume and gain all samples are multiplied with + + + + + + + +
+
+ + self.volume.master +
+
+ Master volume (default 0.5) + + + + + + + +
+
+ + self.volume.sound +
+
+ Additional gain (default 0.5) + + + + + + + +
+
+

Envelope

+ +
+
+ + self.envelope +
+
+ The ASD envelope that controls the sound + amplitude (volume) over time + + + + + + + +
+
+ + self.envelope.attack +
+
+ Attack time: + Time the sound takes to reach its peak amplitude + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.envelope.sustain +
+
+ Sustain time: + Time the sound stays on its peak amplitude + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.envelope.punch +
+
+ Sustain punch: + Amount by which the sound peak amplitude is increased at the start of the + sustain time + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.envelope.decay +
+
+ Decay time: + Time the sound takes to decay after its sustain time + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+

Frequency

+ +
+
+ + self.frequency +
+
+ The base and minimum frequencies of the tone generator and their + slides + + + + + + + +
+
+ + self.frequency.start +
+
+ Start frequency: + Base tone of the sound, before sliding + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.frequency.min +
+
+ Min frequency: + Tone below which the sound will get cut off + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.frequency.slide +
+
+ Slide: + Amount by which the frequency is increased or decreased over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+ + self.frequency.dslide +
+
+ Delta slide: + Amount by which the slide is increased or decreased over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+

Change

+ +
+
+ + self.change +
+
+ Changes the frequency mid-sound to create a characteristic + "coin"-effect + + + + + + + +
+
+ + self.change.amount +
+
+ Change amount: + Amount by which the frequency is changed mid-sound + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+ + self.change.speed +
+
+ Change speed: + Time before the frequency change happens + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+

Duty

+ +
+
+ + self.duty +
+
+ The duty of the square + waveform + + + + + + + +
+
+ + self.duty.ratio +
+
+ Square duty: + Width of the square wave pulse cycle (doesn't affect other waveforms) + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.duty.sweep +
+
+ Duty sweep: + Amount by which the square duty is increased or decreased over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+

Phaser

+ +
+
+ + self.phaser +
+
+ A simple phaser + effect + + + + + + + +
+
+ + self.phaser.offset +
+
+ Phaser offset: + Amount by which the phaser signal is offset from the sound + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+ + self.phaser.sweep +
+
+ Phaser sweep: + Amount by which the phaser offset is increased or decreased over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+

Lowpass

+ +
+
+ + self.lowpass +
+
+ A lowpass filter + effect + + + + + + + +
+
+ + self.lowpass.cutoff +
+
+ Lowpass filter cutoff: + Lower bound for frequencies allowed to pass through this filter + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.lowpass.sweep +
+
+ Lowpass filter cutoff sweep: + Amount by which the LP filter cutoff is increased or decreased + over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+ + self.lowpass.resonance +
+
+ Lowpass filter resonance: + Amount by which certain resonant frequencies near the cutoff are + increased + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+

Highpass

+ +
+
+ + self.highpass +
+
+ A highpass filter + effect + + + + + + + +
+
+ + self.highpass.cutoff +
+
+ Highpass filter cutoff: + Upper bound for frequencies allowed to pass through this filter + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.highpass.sweep +
+
+ Highpass filter cutoff sweep: + Amount by which the HP filter cutoff is increased or decreased + over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+

Vibrato

+ +
+
+ + self.vibrato.depth +
+
+ Vibrato depth: + Amount of amplitude modulation + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.vibrato.speed +
+
+ Vibrato speed: + Oscillation speed of the vibrato + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.vibrato.delay +
+
+ Vibrato delay: + Unused and unimplemented + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+

Randomization

+ +
+
+ + sfxr.Sound:randomize ([seed]) +
+
+ Randomize all sound parameters + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:mutate ([amount=1][, seed], changefreq) +
+
+ Mutate all sound parameters + + +

Parameters:

+
    +
  • amount + number + by how much to mutate the parameters + (default 1) +
  • +
  • seed + number + a random seed + (optional) +
  • +
  • changefreq + bool + whether to change the frequency parameters +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomPickup ([seed]) +
+
+ Randomize all sound parameters to generate a "pick up" sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomLaser ([seed]) +
+
+ Randomize all sound parameters to generate a laser sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomExplosion ([seed]) +
+
+ Randomize all sound parameters to generate an explosion sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomPowerup ([seed]) +
+
+ Randomize all sound parameters to generate a "power up" sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomHit ([seed]) +
+
+ Randomize all sound parameters to generate a hit sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomJump ([seed]) +
+
+ Randomize all sound parameters to generate a jump sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomBlip ([seed]) +
+
+ Randomize all sound parameters to generate a "blip" sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+

Serialization

+ +
+
+ + sfxr.Sound:exportWAV (f[, rate=44100[, depth=0]]) +
+
+ Generate and export the audio data to a PCM WAVE file. + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in wb-mode + (passed files will not be closed) +
  • +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth + (default 0) +
  • +
+ + +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + + +
+
+ + sfxr.Sound:save (f[, minify=true]) +
+
+ Save the sound parameters to a file as a Lua table + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in w-mode + (passed files will not be closed) +
  • +
  • minify + bool + whether to minify the output or not + (default true) +
  • +
+ + + + + +
+
+ + sfxr.Sound:load (f) +
+
+ Load the sound parameters from a file containing a Lua table + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in r-mode + (passed files will not be closed) +
  • +
+ + +

Raises:

+ "incompatible version: x.x.x" + + + +
+
+ + sfxr.Sound:saveBinary (f) +
+
+ Save the sound parameters to a file in the sfxr binary format (version 102) + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in wb-mode + (passed files will not be closed) +
  • +
+ + + + + +
+
+ + sfxr.Sound:loadBinary (f) +
+
+ Load the sound parameters from a file in the sfxr binary format + (version 100-102) + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in rb-mode + (passed files will not be closed) +
  • +
+ + +

Raises:

+ "incompatible version: x", "unexpected file length" + + + +
+
+ + +
+
+
+generated by LDoc 1.4.3 +Last updated 2016-06-12 01:18:55 +
+
+ + diff --git a/libs/sfxrlua/docs/index.html b/libs/sfxrlua/docs/index.html new file mode 100644 index 0000000..5fb1ff3 --- /dev/null +++ b/libs/sfxrlua/docs/index.html @@ -0,0 +1,1799 @@ + + + + + sfxr.lua Documentation + + + + +
+ +
+ +
+
+
+ + +
+ + + + + + +
+ +

Module sfxr

+

A port of the sfxr sound effect synthesizer to pure Lua, designed to be used +together with the awesome LÖVE game framework.

+

+ +

+ + +

Functions

+ + + + + +
newSound (...)Construct and return a new Sound instance.
+

Constants

+ + + + + + + + + + + + + + + + + + + + + +
VERSIONThe module version (SemVer format)
WAVEFORMWaveform constants
SAMPLERATESampling rate constants + (use the number values directly, these are just for lookup)
BITDEPTHBit depth constants + (use the number values directly, these are just for lookup)
ENDIANNESSEndianness constants
+

Class Sound

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sfxr.Sound:__init ()Initialize the Sound instance.
self.vibratoA vibrato-like amplitude + modulation effect + SerializationVibrato
sfxr.Sound:resetParameters ()Set all parameters to their default values.
sfxr.Sound:sanitizeParameters ()Clamp all parameters within their sane ranges.
sfxr.Sound:generate ([rate=44100[, depth=0]])Generate the sound and yield the sample data.
sfxr.Sound:getEnvelopeLimit ([rate=44100])Get the maximum sample limit allowed by the current envelope.
sfxr.Sound:generateTable ([rate=44100[, depth=0[, tab]]])Generate the sound into a table.
sfxr.Sound:generateString ([rate=44100[, depth=16[, endianness=0]]])Generate the sound to a binary string.
sfxr.Sound:generateSoundData ([rate=44100[, depth=0[, sounddata]]])Synthesize the sound to a LÖVE SoundData instance.
+

Parameters

+ + + + + + + + + + + + + +
self.supersamplingNumber of supersampling passes to perform (default 8)
self.repeatspeedRepeat speed: + Times to repeat the frequency slide over the course of the envelope + (default 0.0, min 0.0, max 1.0)
self.waveformThe base waveform (default SQUARE)
+

Volume

+ + + + + + + + + + + + + +
self.volumeThe sound volume and gain all samples are multiplied with
self.volume.masterMaster volume (default 0.5)
self.volume.soundAdditional gain (default 0.5)
+

Envelope

+ + + + + + + + + + + + + + + + + + + + + +
self.envelopeThe ASD envelope that controls the sound + amplitude (volume) over time
self.envelope.attackAttack time: + Time the sound takes to reach its peak amplitude + (default 0.0, min 0.0, max 1.0)
self.envelope.sustainSustain time: + Time the sound stays on its peak amplitude + (default 0.0, min 0.0, max 1.0)
self.envelope.punchSustain punch: + Amount by which the sound peak amplitude is increased at the start of the + sustain time + (default 0.0, min 0.0, max 1.0)
self.envelope.decayDecay time: + Time the sound takes to decay after its sustain time + (default 0.0, min 0.0, max 1.0)
+

Frequency

+ + + + + + + + + + + + + + + + + + + + + +
self.frequencyThe base and minimum frequencies of the tone generator and their + slides
self.frequency.startStart frequency: + Base tone of the sound, before sliding + (default 0.0, min 0.0, max 1.0)
self.frequency.minMin frequency: + Tone below which the sound will get cut off + (default 0.0, min 0.0, max 1.0)
self.frequency.slideSlide: + Amount by which the frequency is increased or decreased over time + (default 0.0, min -1.0, max 1.0)
self.frequency.dslideDelta slide: + Amount by which the slide is increased or decreased over time + (default 0.0, min -1.0, max 1.0)
+

Change

+ + + + + + + + + + + + + +
self.changeChanges the frequency mid-sound to create a characteristic + "coin"-effect
self.change.amountChange amount: + Amount by which the frequency is changed mid-sound + (default 0.0, min -1.0, max 1.0)
self.change.speedChange speed: + Time before the frequency change happens + (default 0.0, min 0.0, max 1.0)
+

Duty

+ + + + + + + + + + + + + +
self.dutyThe duty of the square + waveform
self.duty.ratioSquare duty: + Width of the square wave pulse cycle (doesn't affect other waveforms) + (default 0.0, min 0.0, max 1.0)
self.duty.sweepDuty sweep: + Amount by which the square duty is increased or decreased over time + (default 0.0, min -1.0, max 1.0)
+

Phaser

+ + + + + + + + + + + + + +
self.phaserA simple phaser + effect
self.phaser.offsetPhaser offset: + Amount by which the phaser signal is offset from the sound + (default 0.0, min -1.0, max 1.0)
self.phaser.sweepPhaser sweep: + Amount by which the phaser offset is increased or decreased over time + (default 0.0, min -1.0, max 1.0)
+

Lowpass

+ + + + + + + + + + + + + + + + + +
self.lowpassA lowpass filter + effect
self.lowpass.cutoffLowpass filter cutoff: + Lower bound for frequencies allowed to pass through this filter + (default 0.0, min 0.0, max 1.0)
self.lowpass.sweepLowpass filter cutoff sweep: + Amount by which the LP filter cutoff is increased or decreased + over time + (default 0.0, min -1.0, max 1.0)
self.lowpass.resonanceLowpass filter resonance: + Amount by which certain resonant frequencies near the cutoff are + increased + (default 0.0, min 0.0, max 1.0)
+

Highpass

+ + + + + + + + + + + + + +
self.highpassA highpass filter + effect
self.highpass.cutoffHighpass filter cutoff: + Upper bound for frequencies allowed to pass through this filter + (default 0.0, min 0.0, max 1.0)
self.highpass.sweepHighpass filter cutoff sweep: + Amount by which the HP filter cutoff is increased or decreased + over time + (default 0.0, min -1.0, max 1.0)
+

Vibrato

+ + + + + + + + + + + + + +
self.vibrato.depthVibrato depth: + Amount of amplitude modulation + (default 0.0, min 0.0, max 1.0)
self.vibrato.speedVibrato speed: + Oscillation speed of the vibrato + (default 0.0, min 0.0, max 1.0)
self.vibrato.delayVibrato delay: + Unused and unimplemented + (default 0.0, min 0.0, max 1.0)
+

Randomization

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
sfxr.Sound:randomize ([seed])Randomize all sound parameters
sfxr.Sound:mutate ([amount=1][, seed], changefreq)Mutate all sound parameters
sfxr.Sound:randomPickup ([seed])Randomize all sound parameters to generate a "pick up" sound
sfxr.Sound:randomLaser ([seed])Randomize all sound parameters to generate a laser sound
sfxr.Sound:randomExplosion ([seed])Randomize all sound parameters to generate an explosion sound
sfxr.Sound:randomPowerup ([seed])Randomize all sound parameters to generate a "power up" sound
sfxr.Sound:randomHit ([seed])Randomize all sound parameters to generate a hit sound
sfxr.Sound:randomJump ([seed])Randomize all sound parameters to generate a jump sound
sfxr.Sound:randomBlip ([seed])Randomize all sound parameters to generate a "blip" sound
+

Serialization

+ + + + + + + + + + + + + + + + + + + + + +
sfxr.Sound:exportWAV (f[, rate=44100[, depth=0]])Generate and export the audio data to a PCM WAVE file.
sfxr.Sound:save (f[, minify=true])Save the sound parameters to a file as a Lua table
sfxr.Sound:load (f)Load the sound parameters from a file containing a Lua table
sfxr.Sound:saveBinary (f)Save the sound parameters to a file in the sfxr binary format (version 102)
sfxr.Sound:loadBinary (f)Load the sound parameters from a file in the sfxr binary format + (version 100-102)
+ +
+
+ + +

Functions

+ +
+
+ + newSound (...) +
+
+ Construct and return a new Sound instance. + + +

Parameters:

+
    +
  • ... + + + +
  • +
+ +

Returns:

+
    + + Sound + a Sound instance +
+ + + + +
+
+

Constants

+ +
+
+ + VERSION +
+
+ The module version (SemVer format) + + + + + + + +
+
+ + WAVEFORM +
+
+ Waveform constants + + +

Fields:

+ + + + + + +
+
+ + SAMPLERATE +
+
+ Sampling rate constants + (use the number values directly, these are just for lookup) + + +

Fields:

+
    +
  • 22050 + 22.05 kHz (= 22050) +
  • +
  • 44100 + 44.1 kHz (= 44100) +
  • +
+ + + + + +
+
+ + BITDEPTH +
+
+ Bit depth constants + (use the number values directly, these are just for lookup) + + +

Fields:

+
    +
  • 0 + floating point bit depth, -1 to 1 (= 0) +
  • +
  • 8 + unsigned 8 bit, 0x00 to 0xFF (= 8) +
  • +
  • 16 + unsigned 16 bit, 0x0000 to 0xFFFF (= 16) +
  • +
+ + + + + +
+
+ + ENDIANNESS +
+
+ Endianness constants + + +

Fields:

+
    +
  • LITTLE + little endian (= 0) +
  • +
  • BIG + big endian (= 1) +
  • +
+ + + + + +
+
+

Class Sound

+ +
+ The main Sound class. +
+
+
+ + sfxr.Sound:__init () +
+
+ Initialize the Sound instance. + Called by the constructor. + + + + + + + +
+
+ + self.vibrato +
+
+ A vibrato-like amplitude + modulation effect + SerializationVibrato + + + + + + + +
+
+ + sfxr.Sound:resetParameters () +
+
+ Set all parameters to their default values. Does not affect + supersampling and volume. + Called by the initializer. + + + + + + + +
+
+ + sfxr.Sound:sanitizeParameters () +
+
+ Clamp all parameters within their sane ranges. + + + + + + + +
+
+ + sfxr.Sound:generate ([rate=44100[, depth=0]]) +
+
+ Generate the sound and yield the sample data. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth + (default 0) +
  • +
+ +

Returns:

+
    + + function() + a generator that yields the next sample when called +
+ +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + +

Usage:

+
    +
     for s in sound:generate(44100, 0) do
    +   -- do something with s
    + end
    +
+ +
+
+ + sfxr.Sound:getEnvelopeLimit ([rate=44100]) +
+
+ Get the maximum sample limit allowed by the current envelope. + Does not take any other limits into account, so the returned count might be + higher than samples actually generated. Still useful though. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
+ + +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + + +
+
+ + sfxr.Sound:generateTable ([rate=44100[, depth=0[, tab]]]) +
+
+ Generate the sound into a table. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth + (default 0) +
  • +
  • tab + {} + the table to synthesize into + (optional) +
  • +
+ +

Returns:

+
    +
  1. + {number,...} + the table filled with sample data
  2. +
  3. + int + the number of written samples (== #tab)
  4. +
+ +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + + +
+
+ + sfxr.Sound:generateString ([rate=44100[, depth=16[, endianness=0]]]) +
+
+ Generate the sound to a binary string. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth (may not be 0) + (default 16) +
  • +
  • endianness + ENDIANNESS + the endianness (ignored when depth == 8) + (default 0) +
  • +
+ +

Returns:

+
    +
  1. + string + a binary string of sample data
  2. +
  3. + int + the number of written samples
  4. +
+ +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x", "invalid endianness: x" + + + +
+
+ + sfxr.Sound:generateSoundData ([rate=44100[, depth=0[, sounddata]]]) +
+
+ Synthesize the sound to a LÖVE SoundData instance. + + +

Parameters:

+
    +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth + (default 0) +
  • +
  • sounddata + love.sound.SoundData + a SoundData instance (will be + created if not passed) + (optional) +
  • +
+ +

Returns:

+
    +
  1. + love.sound.SoundData + a SoundData instance
  2. +
  3. + int + the number of written samples
  4. +
+ +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + + +
+
+

Parameters

+ +
+
+ + self.supersampling +
+
+ Number of supersampling passes to perform (default 8) + + + + + + + +
+
+ + self.repeatspeed +
+
+ Repeat speed: + Times to repeat the frequency slide over the course of the envelope + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.waveform +
+
+ The base waveform (default SQUARE) + + + + + + + +
+
+

Volume

+ +
+
+ + self.volume +
+
+ The sound volume and gain all samples are multiplied with + + + + + + + +
+
+ + self.volume.master +
+
+ Master volume (default 0.5) + + + + + + + +
+
+ + self.volume.sound +
+
+ Additional gain (default 0.5) + + + + + + + +
+
+

Envelope

+ +
+
+ + self.envelope +
+
+ The ASD envelope that controls the sound + amplitude (volume) over time + + + + + + + +
+
+ + self.envelope.attack +
+
+ Attack time: + Time the sound takes to reach its peak amplitude + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.envelope.sustain +
+
+ Sustain time: + Time the sound stays on its peak amplitude + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.envelope.punch +
+
+ Sustain punch: + Amount by which the sound peak amplitude is increased at the start of the + sustain time + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.envelope.decay +
+
+ Decay time: + Time the sound takes to decay after its sustain time + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+

Frequency

+ +
+
+ + self.frequency +
+
+ The base and minimum frequencies of the tone generator and their + slides + + + + + + + +
+
+ + self.frequency.start +
+
+ Start frequency: + Base tone of the sound, before sliding + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.frequency.min +
+
+ Min frequency: + Tone below which the sound will get cut off + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.frequency.slide +
+
+ Slide: + Amount by which the frequency is increased or decreased over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+ + self.frequency.dslide +
+
+ Delta slide: + Amount by which the slide is increased or decreased over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+

Change

+ +
+
+ + self.change +
+
+ Changes the frequency mid-sound to create a characteristic + "coin"-effect + + + + + + + +
+
+ + self.change.amount +
+
+ Change amount: + Amount by which the frequency is changed mid-sound + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+ + self.change.speed +
+
+ Change speed: + Time before the frequency change happens + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+

Duty

+ +
+
+ + self.duty +
+
+ The duty of the square + waveform + + + + + + + +
+
+ + self.duty.ratio +
+
+ Square duty: + Width of the square wave pulse cycle (doesn't affect other waveforms) + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.duty.sweep +
+
+ Duty sweep: + Amount by which the square duty is increased or decreased over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+

Phaser

+ +
+
+ + self.phaser +
+
+ A simple phaser + effect + + + + + + + +
+
+ + self.phaser.offset +
+
+ Phaser offset: + Amount by which the phaser signal is offset from the sound + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+ + self.phaser.sweep +
+
+ Phaser sweep: + Amount by which the phaser offset is increased or decreased over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+

Lowpass

+ +
+
+ + self.lowpass +
+
+ A lowpass filter + effect + + + + + + + +
+
+ + self.lowpass.cutoff +
+
+ Lowpass filter cutoff: + Lower bound for frequencies allowed to pass through this filter + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.lowpass.sweep +
+
+ Lowpass filter cutoff sweep: + Amount by which the LP filter cutoff is increased or decreased + over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+ + self.lowpass.resonance +
+
+ Lowpass filter resonance: + Amount by which certain resonant frequencies near the cutoff are + increased + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+

Highpass

+ +
+
+ + self.highpass +
+
+ A highpass filter + effect + + + + + + + +
+
+ + self.highpass.cutoff +
+
+ Highpass filter cutoff: + Upper bound for frequencies allowed to pass through this filter + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.highpass.sweep +
+
+ Highpass filter cutoff sweep: + Amount by which the HP filter cutoff is increased or decreased + over time + (default 0.0, min -1.0, max 1.0) + + + + + + + +
+
+

Vibrato

+ +
+
+ + self.vibrato.depth +
+
+ Vibrato depth: + Amount of amplitude modulation + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.vibrato.speed +
+
+ Vibrato speed: + Oscillation speed of the vibrato + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+ + self.vibrato.delay +
+
+ Vibrato delay: + Unused and unimplemented + (default 0.0, min 0.0, max 1.0) + + + + + + + +
+
+

Randomization

+ +
+
+ + sfxr.Sound:randomize ([seed]) +
+
+ Randomize all sound parameters + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:mutate ([amount=1][, seed], changefreq) +
+
+ Mutate all sound parameters + + +

Parameters:

+
    +
  • amount + number + by how much to mutate the parameters + (default 1) +
  • +
  • seed + number + a random seed + (optional) +
  • +
  • changefreq + bool + whether to change the frequency parameters +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomPickup ([seed]) +
+
+ Randomize all sound parameters to generate a "pick up" sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomLaser ([seed]) +
+
+ Randomize all sound parameters to generate a laser sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomExplosion ([seed]) +
+
+ Randomize all sound parameters to generate an explosion sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomPowerup ([seed]) +
+
+ Randomize all sound parameters to generate a "power up" sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomHit ([seed]) +
+
+ Randomize all sound parameters to generate a hit sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomJump ([seed]) +
+
+ Randomize all sound parameters to generate a jump sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+ + sfxr.Sound:randomBlip ([seed]) +
+
+ Randomize all sound parameters to generate a "blip" sound + + +

Parameters:

+
    +
  • seed + number + a random seed + (optional) +
  • +
+ + + + + +
+
+

Serialization

+ +
+
+ + sfxr.Sound:exportWAV (f[, rate=44100[, depth=0]]) +
+
+ Generate and export the audio data to a PCM WAVE file. + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in wb-mode + (passed files will not be closed) +
  • +
  • rate + SAMPLERATE + the sampling rate + (default 44100) +
  • +
  • depth + BITDEPTH + the bit depth + (default 0) +
  • +
+ + +

Raises:

+ "invalid sampling rate: x", "invalid bit depth: x" + + + +
+
+ + sfxr.Sound:save (f[, minify=true]) +
+
+ Save the sound parameters to a file as a Lua table + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in w-mode + (passed files will not be closed) +
  • +
  • minify + bool + whether to minify the output or not + (default true) +
  • +
+ + + + + +
+
+ + sfxr.Sound:load (f) +
+
+ Load the sound parameters from a file containing a Lua table + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in r-mode + (passed files will not be closed) +
  • +
+ + +

Raises:

+ "incompatible version: x.x.x" + + + +
+
+ + sfxr.Sound:saveBinary (f) +
+
+ Save the sound parameters to a file in the sfxr binary format (version 102) + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in wb-mode + (passed files will not be closed) +
  • +
+ + + + + +
+
+ + sfxr.Sound:loadBinary (f) +
+
+ Load the sound parameters from a file in the sfxr binary format + (version 100-102) + + +

Parameters:

+
    +
  • f + string, file or love.filesystem.File + a path or file in rb-mode + (passed files will not be closed) +
  • +
+ + +

Raises:

+ "incompatible version: x", "unexpected file length" + + + +
+
+ + +
+
+
+generated by LDoc 1.4.3 +Last updated 2016-06-12 01:18:55 +
+
+ + diff --git a/libs/sfxrlua/docs/ldoc_pale.css b/libs/sfxrlua/docs/ldoc_pale.css new file mode 100644 index 0000000..b071110 --- /dev/null +++ b/libs/sfxrlua/docs/ldoc_pale.css @@ -0,0 +1,304 @@ +/* BEGIN RESET + +Copyright (c) 2010, Yahoo! Inc. All rights reserved. +Code licensed under the BSD License: +http://developer.yahoo.com/yui/license.html +version: 2.8.2r1 +*/ +html { + color: #000; + background: #FFF; +} +body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,button,textarea,p,blockquote,th,td { + margin: 0; + padding: 0; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +fieldset,img { + border: 0; +} +address,caption,cite,code,dfn,em,strong,th,var,optgroup { + font-style: inherit; + font-weight: inherit; +} +del,ins { + text-decoration: none; +} +li { + list-style: disc; + margin-left: 20px; +} +caption,th { + text-align: left; +} +h1,h2,h3,h4,h5,h6 { + font-size: 100%; + font-weight: bold; +} +q:before,q:after { + content: ''; +} +abbr,acronym { + border: 0; + font-variant: normal; +} +sup { + vertical-align: baseline; +} +sub { + vertical-align: baseline; +} +legend { + color: #000; +} +input,button,textarea,select,optgroup,option { + font-family: inherit; + font-size: inherit; + font-style: inherit; + font-weight: inherit; +} +input,button,textarea,select {*font-size:100%; +} +/* END RESET */ + +body { + margin-left: 1em; + margin-right: 1em; + font-family: arial, helvetica, geneva, sans-serif; + background-color: #ffffff; margin: 0px; +} + +code, tt { font-family: monospace; font-size: 1.1em; } +span.parameter { font-family:monospace; } +span.parameter:after { content:":"; } +span.types:before { content:"("; } +span.types:after { content:")"; } +.type { font-weight: bold; font-style:italic } + +body, p, td, th { font-size: .95em; line-height: 1.2em;} + +p, ul { margin: 10px 0 0 0px;} + +strong { font-weight: bold;} + +em { font-style: italic;} + +h1 { + font-size: 1.5em; + margin: 0 0 20px 0; +} +h2, h3, h4 { margin: 15px 0 10px 0; } +h2 { font-size: 1.25em; } +h3 { font-size: 1.15em; } +h4 { font-size: 1.06em; } + +a:link { font-weight: bold; color: #004080; text-decoration: none; } +a:visited { font-weight: bold; color: #006699; text-decoration: none; } +a:link:hover { text-decoration: underline; } + +hr { + color:#cccccc; + background: #00007f; + height: 1px; +} + +blockquote { margin-left: 3em; } + +ul { list-style-type: disc; } + +p.name { + font-family: "Andale Mono", monospace; + padding-top: 1em; +} + +pre { + background-color: rgb(245, 245, 245); + border: 1px solid #C0C0C0; /* silver */ + padding: 10px; + margin: 10px 0 10px 0; + overflow: auto; + font-family: "Andale Mono", monospace; +} + +pre.example { + font-size: .85em; +} + +table.index { border: 1px #00007f; } +table.index td { text-align: left; vertical-align: top; } + +#container { + margin-left: 1em; + margin-right: 1em; + background-color: #ffffff; +} + +#product { + text-align: center; + border-bottom: 1px solid #cccccc; + background-color: #ffffff; +} + +#product big { + font-size: 2em; +} + +#main { + background-color:#FFFFFF; // #f0f0f0; + //border-left: 2px solid #cccccc; +} + +#navigation { + float: left; + width: 14em; + vertical-align: top; + background-color:#FFFFFF; // #f0f0f0; + border-right: 2px solid #cccccc; + overflow: visible; +} + +#navigation h2 { + background-color:#FFFFFF;//:#e7e7e7; + font-size:1.1em; + color:#000000; + text-align: left; + padding:0.2em; + //border-top:1px solid #dddddd; + border-bottom:1px solid #dddddd; +} + +#navigation ul +{ + font-size:1em; + list-style-type: none; + margin: 1px 1px 10px 1px; +} + +#navigation li { + text-indent: -1em; + display: block; + margin: 3px 0px 0px 22px; +} + +#navigation li li a { + margin: 0px 3px 0px -1em; +} + +#content { + margin-left: 14em; + padding: 1em; + width: 700px; + border-left: 2px solid #cccccc; + // border-right: 2px solid #cccccc; + background-color: #ffffff; +} + +#about { + clear: both; + padding: 5px; + border-top: 2px solid #cccccc; + background-color: #ffffff; +} + +@media print { + body { + font: 12pt "Times New Roman", "TimeNR", Times, serif; + } + a { font-weight: bold; color: #004080; text-decoration: underline; } + + #main { + background-color: #ffffff; + border-left: 0px; + } + + #container { + margin-left: 2%; + margin-right: 2%; + background-color: #ffffff; + } + + #content { + padding: 1em; + background-color: #ffffff; + } + + #navigation { + display: none; + } + pre.example { + font-family: "Andale Mono", monospace; + font-size: 10pt; + page-break-inside: avoid; + } +} + +table.module_list { + border-width: 1px; + border-style: solid; + border-color: #cccccc; + border-collapse: collapse; +} +table.module_list td { + border-width: 1px; + padding: 3px; + border-style: solid; + border-color: #cccccc; +} +table.module_list td.name { background-color: #f0f0f0; ; min-width: 200px; } +table.module_list td.summary { width: 100%; } + +table.function_list { + border-width: 1px; + border-style: solid; + border-color: #cccccc; + border-collapse: collapse; +} +table.function_list td { + border-width: 1px; + padding: 3px; + border-style: solid; + border-color: #cccccc; +} +table.function_list td.name { background-color: #f6f6ff; ; min-width: 200px; } +table.function_list td.summary { width: 100%; } + +dl.table dt, dl.function dt {border-top: 1px solid #ccc; padding-top: 1em;} +dl.table dd, dl.function dd {padding-bottom: 1em; margin: 10px 0 0 20px;} +dl.table h3, dl.function h3 {font-size: .95em;} + +ul.nowrap { + overflow:auto; + whitespace:nowrap; +} + +/* stop sublists from having initial vertical space */ +ul ul { margin-top: 0px; } +ol ul { margin-top: 0px; } +ol ol { margin-top: 0px; } +ul ol { margin-top: 0px; } + +/* make the target distinct; helps when we're navigating to a function */ +a:target + * { + background-color: #FF9; +} + + +/* styles for prettification of source */ +pre .comment { color: #558817; } +pre .constant { color: #a8660d; } +pre .escape { color: #844631; } +pre .keyword { color: #aa5050; font-weight: bold; } +pre .library { color: #0e7c6b; } +pre .marker { color: #512b1e; background: #fedc56; font-weight: bold; } +pre .string { color: #8080ff; } +pre .number { color: #f8660d; } +pre .operator { color: #2239a8; font-weight: bold; } +pre .preprocessor, pre .prepro { color: #a33243; } +pre .global { color: #800080; } +pre .user-keyword { color: #800080; } +pre .prompt { color: #558817; } +pre .url { color: #272fc2; text-decoration: underline; } + diff --git a/libs/sfxrlua/rockspec/sfxr-0.0.2-1.rockspec b/libs/sfxrlua/rockspec/sfxr-0.0.2-1.rockspec new file mode 100644 index 0000000..c528fac --- /dev/null +++ b/libs/sfxrlua/rockspec/sfxr-0.0.2-1.rockspec @@ -0,0 +1,28 @@ +package = "sfxr" +version = "0.0.2-1" +source = { + url = "git://github.com/nucular/sfxrlua", + tag = "v0.0.2" +} +description = { + summary = "A port of the sfxr sound effect synthesizer to Lua", + detailed = [[ + A port of the sfxr sound effect synthesizer to pure Lua, designed to be used + together with the awesome LÖVE game framework. + ]], + homepage = "https://github.com/nucular/sfxrlua", + license = "MIT/X11" +} +dependencies = { + "lua ~> 5.1" + -- bitop? +} +build = { + type = "builtin", + modules = { + sfxr = "sfxr.lua" + }, + copy_directories = { + "docs" + } +} diff --git a/libs/sfxrlua/rockspec/sfxr-scm-1.rockspec b/libs/sfxrlua/rockspec/sfxr-scm-1.rockspec new file mode 100644 index 0000000..c64773e --- /dev/null +++ b/libs/sfxrlua/rockspec/sfxr-scm-1.rockspec @@ -0,0 +1,28 @@ +package = "sfxr" +version = "scm-1" +source = { + url = "git://github.com/nucular/sfxrlua", + branch = "master" +} +description = { + summary = "A port of the sfxr sound effect synthesizer to Lua", + detailed = [[ + A port of the sfxr sound effect synthesizer to pure Lua, designed to be used + together with the awesome LÖVE game framework. + ]], + homepage = "https://github.com/nucular/sfxrlua", + license = "MIT/X11" +} +dependencies = { + "lua ~> 5.1" + -- bitop? +} +build = { + type = "builtin", + modules = { + sfxr = "sfxr.lua" + }, + copy_directories = { + "docs" + } +} diff --git a/libs/sfxrlua/sfxr.lua b/libs/sfxrlua/sfxr.lua new file mode 100644 index 0000000..d1e0342 --- /dev/null +++ b/libs/sfxrlua/sfxr.lua @@ -0,0 +1,1536 @@ +-- sfxr.lua +-- original by Tomas Pettersson, ported to Lua by nucular + +--[[ +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. +]]-- + +--[[-- +A port of the sfxr sound effect synthesizer to pure Lua, designed to be used +together with the *awesome* [LÖVE](https://love2d.org) game framework. +]]-- +-- @module sfxr +local sfxr = {} +local bit = bit32 or require("bit") + +-- Constants + +--- The module version (SemVer format) +-- @within Constants +sfxr.VERSION = "0.0.2" + +--- [Waveform](https://en.wikipedia.org/wiki/Waveform) constants +-- @within Constants +-- @field SQUARE [square wave](https://en.wikipedia.org/wiki/Square_wave) (`= 0`) +-- @field SAWTOOTH [sawtooth wave](https://en.wikipedia.org/wiki/Sawtooth_wave) (`= 1`) +-- @field SINE [sine wave](https://en.wikipedia.org/wiki/Sine_wave) (`= 2`) +-- @field NOISE [white noise](https://en.wikipedia.org/wiki/White_noise) (`= 3`) +sfxr.WAVEFORM = { + SQUARE = 0, + [0] = 0, + SAWTOOTH = 1, + [1] = 1, + SINE = 2, + [2] = 2, + NOISE = 3, + [3] = 3 +} + +--- [Sampling rate](https://en.wikipedia.org/wiki/Sampling_(signal_processing)#Sampling_rate) constants +-- (use the number values directly, these are just for lookup) +-- @within Constants +-- @field 22050 22.05 kHz (`= 22050`) +-- @field 44100 44.1 kHz (`= 44100`) +sfxr.SAMPLERATE = { + [22050] = 22050, --- 22.05 kHz + [44100] = 44100 --- 44.1 kHz +} + +--- [Bit depth](https://en.wikipedia.org/wiki/Audio_bit_depth) constants +-- (use the number values directly, these are just for lookup) +-- @within Constants +-- @field 0 floating point bit depth, -1 to 1 (`= 0`) +-- @field 8 unsigned 8 bit, 0x00 to 0xFF (`= 8`) +-- @field 16 unsigned 16 bit, 0x0000 to 0xFFFF (`= 16`) +sfxr.BITDEPTH = { + [0] = 0, + [16] = 16, + [8] = 8 +} + +--- [Endianness](https://en.wikipedia.org/wiki/Endianness) constants +-- @within Constants +-- @field LITTLE little endian (`= 0`) +-- @field BIG big endian (`= 1`) +sfxr.ENDIANNESS = { + LITTLE = 0, + [0] = 0, + BIG = 1, + [1] = 1 +} + +-- Utilities + +--- Truncate a number to an unsigned integer. +-- @tparam number n a (signed) number +-- @treturn int the number, truncated and unsigned +local function trunc(n) + if n >= 0 then + return math.floor(n) + else + return -math.floor(-n) + end +end + +--- Set the random seed and initializes the generator. +-- @tparam number seed the random seed +local function setseed(seed) + math.randomseed(seed) + for i=0, 5 do + math.random() + end +end + +--- Return a random number between low and high. +-- @tparam number low the lower bound +-- @tparam number high the upper bound +-- @treturn number a random number where `low < n < high` +local function random(low, high) + return low + math.random() * (high - low) +end + +--- Return a random boolean weighted towards false by n. +-- w = 1: uniform distribution +-- w = n: false is n times as likely as true +-- Note: n < 0 do not work, use `not maybe(w)` instead +-- @tparam[opt=1] number w the weight towards false +-- @treturn bool a random boolean +local function maybe(w) + return trunc(random(0, w or 1)) == 0 +end + +--- Clamp n between min and max. +-- @tparam number n the number +-- @tparam number min the lower bound +-- @tparam number max the upper bound +-- @treturn number the number where `min <= n <= max` +local function clamp(n, min, max) + return math.max(min or -math.huge, math.min(max or math.huge, n)) +end + +--- Copy a table (shallow) or a primitive. +-- @param t a table or primitive +-- @return a copy of t +local function shallowcopy(t) + if type(t) == "table" then + local t2 = {} + for k,v in pairs(t) do + t2[k] = v + end + return t2 + else + return t + end +end + +--- Recursively merge table t2 into t1. +-- @tparam tab t1 a table +-- @tparam tab t2 a table to merge into t1 +-- @treturn tab t1 +local function mergetables(t1, t2) + for k, v in pairs(t2) do + if type(v) == "table" then + if type(t1[k] or false) == "table" then + mergetables(t1[k] or {}, t2[k] or {}) + else + t1[k] = v + end + else + t1[k] = v + end + end + return t1 +end + +--- Pack a number into a IEEE754 32-bit big-endian floating point binary string. +-- [source](https://stackoverflow.com/questions/14416734/) +-- @tparam number number a number +-- @treturn string a binary string +local function packIEEE754(number) + if number == 0 then + return string.char(0x00, 0x00, 0x00, 0x00) + elseif number ~= number then + return string.char(0xFF, 0xFF, 0xFF, 0xFF) + else + local sign = 0x00 + if number < 0 then + sign = 0x80 + number = -number + end + local mantissa, exponent = math.frexp(number) + exponent = exponent + 0x7F + if exponent <= 0 then + mantissa = math.ldexp(mantissa, exponent - 1) + exponent = 0 + elseif exponent > 0 then + if exponent >= 0xFF then + return string.char(sign + 0x7F, 0x80, 0x00, 0x00) + elseif exponent == 1 then + exponent = 0 + else + mantissa = mantissa * 2 - 1 + exponent = exponent - 1 + end + end + mantissa = math.floor(math.ldexp(mantissa, 23) + 0.5) + return string.char( + sign + math.floor(exponent / 2), + (exponent % 2) * 0x80 + math.floor(mantissa / 0x10000), + math.floor(mantissa / 0x100) % 0x100, + mantissa % 0x100) + end +end + +--- Unpack a IEEE754 32-bit big-endian floating point string to a number. +-- [source](https://stackoverflow.com/questions/14416734/) +-- @tparam string packed a binary string +-- @treturn number a number +local function unpackIEEE754(packed) + local b1, b2, b3, b4 = string.byte(packed, 1, 4) + local exponent = (b1 % 0x80) * 0x02 + math.floor(b2 / 0x80) + local mantissa = math.ldexp(((b2 % 0x80) * 0x100 + b3) * 0x100 + b4, -23) + if exponent == 0xFF then + if mantissa > 0 then + return 0 / 0 + else + mantissa = math.huge + exponent = 0x7F + end + elseif exponent > 0 then + mantissa = mantissa + 1 + else + exponent = exponent + 1 + end + if b1 >= 0x80 then + mantissa = -mantissa + end + return math.ldexp(mantissa, exponent - 0x7F) +end + +--- Construct and return a new @{Sound} instance. +-- @treturn Sound a Sound instance +function sfxr.newSound(...) + local instance = setmetatable({}, sfxr.Sound) + instance:__init(...) + return instance +end + +--- The main Sound class. +-- @type Sound +sfxr.Sound = {} +sfxr.Sound.__index = sfxr.Sound + +--- Initialize the Sound instance. +-- Called by @{sfxr.newSound|the constructor}. +function sfxr.Sound:__init() + --- Number of supersampling passes to perform (*default* 8) + -- @within Parameters + self.supersampling = 8 + --- Repeat speed: + -- Times to repeat the frequency slide over the course of the envelope + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Parameters + self.repeatspeed = 0.0 + --- The base @{WAVEFORM|waveform} (*default* @{WAVEFORM|SQUARE}) + -- @within Parameters + self.waveform = sfxr.WAVEFORM.SQUARE + + -- Build tables to store the parameters in + + --- **The sound volume and gain all samples are multiplied with** + -- @within Volume + self.volume = {} + --- **The [ASD envelope](https://en.wikipedia.org/wiki/Synthesizer#Attack_ + --Decay_Sustain_Release_.28ADSR.29_envelope) that controls the sound + -- amplitude (volume) over time** + -- @within Envelope + self.envelope = {} + --- **The base and minimum frequencies of the tone generator and their + -- slides** + -- @within Frequency + self.frequency = {} + --- **A [vibrato](https://en.wikipedia.org/wiki/Vibrato)-like amplitude + -- modulation effect** + -- SerializationVibrato + self.vibrato = {} + --- **Changes the frequency mid-sound to create a characteristic + -- "coin"-effect** + -- @within Change + self.change = {} + --- **The [duty](https://en.wikipedia.org/wiki/Duty_cycle) of the square + -- waveform** + -- @within Duty + self.duty = {} + --- **A simple [phaser](https://en.wikipedia.org/wiki/Phaser_(effect)) + -- effect** + -- @within Phaser + self.phaser = {} + --- **A [lowpass filter](https://en.wikipedia.org/wiki/Low-pass_filter) + -- effect** + -- @within Lowpass + self.lowpass = {} + --- **A [highpass filter](https://en.wikipedia.org/wiki/High-pass_filter) + -- effect** + -- @within Highpass + self.highpass = {} + + -- These are not affected by resetParameters() + + --- Master volume (*default* 0.5) + -- @within Volume + self.volume.master = 0.5 + --- Additional gain (*default* 0.5) + -- @within Volume + self.volume.sound = 0.5 + + self:resetParameters() +end + +--- Set all parameters to their default values. Does not affect +-- @{self.supersampling|supersampling} and @{self.volume|volume}. +-- Called by @{sfxr.Sound:__init|the initializer}. +function sfxr.Sound:resetParameters() + self.repeatspeed = 0.0 + self.waveform = sfxr.WAVEFORM.SQUARE + + --- Attack time: + -- Time the sound takes to reach its peak amplitude + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Envelope + self.envelope.attack = 0.0 + --- Sustain time: + -- Time the sound stays on its peak amplitude + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Envelope + self.envelope.sustain = 0.3 + --- Sustain punch: + -- Amount by which the sound peak amplitude is increased at the start of the + -- sustain time + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Envelope + self.envelope.punch = 0.0 + --- Decay time: + -- Time the sound takes to decay after its sustain time + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Envelope + self.envelope.decay = 0.4 + + --- Start frequency: + -- Base tone of the sound, before sliding + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Frequency + self.frequency.start = 0.3 + --- Min frequency: + -- Tone below which the sound will get cut off + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Frequency + self.frequency.min = 0.0 + --- Slide: + -- Amount by which the frequency is increased or decreased over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Frequency + self.frequency.slide = 0.0 + --- Delta slide: + -- Amount by which the slide is increased or decreased over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Frequency + self.frequency.dslide = 0.0 + + --- Vibrato depth: + -- Amount of amplitude modulation + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Vibrato + self.vibrato.depth = 0.0 + --- Vibrato speed: + -- Oscillation speed of the vibrato + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Vibrato + self.vibrato.speed = 0.0 + --- Vibrato delay: + -- Unused and unimplemented + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Vibrato + self.vibrato.delay = 0.0 + + --- Change amount: + -- Amount by which the frequency is changed mid-sound + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Change + self.change.amount = 0.0 + --- Change speed: + -- Time before the frequency change happens + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Change + self.change.speed = 0.0 + + --- Square duty: + -- Width of the square wave pulse cycle (doesn't affect other waveforms) + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Duty + self.duty.ratio = 0.0 + --- Duty sweep: + -- Amount by which the square duty is increased or decreased over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Duty + self.duty.sweep = 0.0 + + --- Phaser offset: + -- Amount by which the phaser signal is offset from the sound + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Phaser + self.phaser.offset = 0.0 + --- Phaser sweep: + -- Amount by which the phaser offset is increased or decreased over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Phaser + self.phaser.sweep = 0.0 + + --- Lowpass filter cutoff: + -- Lower bound for frequencies allowed to pass through this filter + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Lowpass + self.lowpass.cutoff = 1.0 + --- Lowpass filter cutoff sweep: + -- Amount by which the LP filter cutoff is increased or decreased + -- over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Lowpass + self.lowpass.sweep = 0.0 + --- Lowpass filter resonance: + -- Amount by which certain resonant frequencies near the cutoff are + -- increased + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Lowpass + self.lowpass.resonance = 0.0 + --- Highpass filter cutoff: + -- Upper bound for frequencies allowed to pass through this filter + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Highpass + self.highpass.cutoff = 0.0 + --- Highpass filter cutoff sweep: + -- Amount by which the HP filter cutoff is increased or decreased + -- over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Highpass + self.highpass.sweep = 0.0 +end + +--- Clamp all parameters within their sane ranges. +function sfxr.Sound:sanitizeParameters() + self.repeatspeed = clamp(self.repeatspeed, 0, 1) + self.waveform = clamp(self.waveform, 0, #sfxr.WAVEFORM) + + self.envelope.attack = clamp(self.envelope.attack, 0, 1) + self.envelope.sustain = clamp(self.envelope.sustain, 0, 1) + self.envelope.punch = clamp(self.envelope.punch, 0, 1) + self.envelope.decay = clamp(self.envelope.decay, 0, 1) + + self.frequency.start = clamp(self.frequency.start, 0, 1) + self.frequency.min = clamp(self.frequency.min, 0, 1) + self.frequency.slide = clamp(self.frequency.slide, -1, 1) + self.frequency.dslide = clamp(self.frequency.dslide, -1, 1) + + self.vibrato.depth = clamp(self.vibrato.depth, 0, 1) + self.vibrato.speed = clamp(self.vibrato.speed, 0, 1) + self.vibrato.delay = clamp(self.vibrato.delay, 0, 1) + + self.change.amount = clamp(self.change.amount, -1, 1) + self.change.speed = clamp(self.change.speed, 0, 1) + + self.duty.ratio = clamp(self.duty.ratio, 0, 1) + self.duty.sweep = clamp(self.duty.sweep, -1, 1) + + self.phaser.offset = clamp(self.phaser.offset, -1, 1) + self.phaser.sweep = clamp(self.phaser.sweep, -1, 1) + + self.lowpass.cutoff = clamp(self.lowpass.cutoff, 0, 1) + self.lowpass.sweep = clamp(self.lowpass.sweep, -1, 1) + self.lowpass.resonance = clamp(self.lowpass.resonance, 0, 1) + self.highpass.cutoff = clamp(self.highpass.cutoff, 0, 1) + self.highpass.sweep = clamp(self.highpass.sweep, -1, 1) +end + +--- Generate the sound and yield the sample data. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=0] BITDEPTH depth the bit depth +-- @treturn function() a generator that yields the next sample when called +-- @usage for s in sound:generate(44100, 0) do +-- -- do something with s +-- end +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:generate(rate, depth) + rate = rate or 44100 + depth = depth or 0 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth], "invalid bit depth: " .. tostring(depth)) + + -- Initialize all locals + local fperiod, maxperiod + local slide, dslide + local square_duty, square_slide + local chg_mod, chg_time, chg_limit + + local phaserbuffer = {} + local noisebuffer = {} + + -- Initialize the sample buffers + for i=1, 1024 do + phaserbuffer[i] = 0 + end + + for i=1, 32 do + noisebuffer[i] = random(-1, 1) + end + + --- Reset the sound period + local function reset() + fperiod = 100 / (self.frequency.start^2 + 0.001) + maxperiod = 100 / (self.frequency.min^2 + 0.001) + period = trunc(fperiod) + + slide = 1.0 - self.frequency.slide^3 * 0.01 + dslide = -self.frequency.dslide^3 * 0.000001 + + square_duty = 0.5 - self.duty.ratio * 0.5 + square_slide = -self.duty.sweep * 0.00005 + + if self.change.amount >= 0 then + chg_mod = 1.0 - self.change.amount^2 * 0.9 + else + chg_mod = 1.0 + self.change.amount^2 * 10 + end + + chg_time = 0 + if self.change.speed == 1 then + chg_limit = 0 + else + chg_limit = trunc((1 - self.change.speed)^2 * 20000 + 32) + end + end + + local phase = 0 + reset() + + local second_sample = false + + local env_vol = 0 + local env_stage = 1 + local env_time = 0 + local env_length = {self.envelope.attack^2 * 100000, + self.envelope.sustain^2 * 100000, + self.envelope.decay^2 * 100000} + + local fphase = self.phaser.offset^2 * 1020 + if self.phaser.offset < 0 then fphase = -fphase end + local dphase = self.phaser.sweep^2 + if self.phaser.sweep < 0 then dphase = -dphase end + local ipp = 0 + + local iphase = math.abs(trunc(fphase)) + + local fltp = 0 + local fltdp = 0 + local fltw = self.lowpass.cutoff^3 * 0.1 + local fltw_d = 1 + self.lowpass.sweep * 0.0001 + local fltdmp = 5 / (1 + self.lowpass.resonance^2 * 20) * (0.01 + fltw) + fltdmp = clamp(fltdmp, nil, 0.8) + local fltphp = 0 + local flthp = self.highpass.cutoff^2 * 0.1 + local flthp_d = 1 + self.highpass.sweep * 0.0003 + + local vib_phase = 0 + local vib_speed = self.vibrato.speed^2 * 0.01 + local vib_amp = self.vibrato.depth * 0.5 + + local rep_time = 0 + local rep_limit = trunc((1 - self.repeatspeed)^2 * 20000 + 32) + if self.repeatspeed == 0 then + rep_limit = 0 + end + + -- The main closure (returned as a generator) + + local function next() + -- Repeat when needed + rep_time = rep_time + 1 + if rep_limit ~= 0 and rep_time >= rep_limit then + rep_time = 0 + reset() + end + + -- Update the change time and apply it if needed + chg_time = chg_time + 1 + if chg_limit ~= 0 and chg_time >= chg_limit then + chg_limit = 0 + fperiod = fperiod * chg_mod + end + + -- Apply the frequency slide and stuff + slide = slide + dslide + fperiod = fperiod * slide + + if fperiod > maxperiod then + fperiod = maxperiod + -- Fail if the minimum frequency is too small + if (self.frequency.min > 0) then + return nil + end + end + + -- Vibrato + local rfperiod = fperiod + if vib_amp > 0 then + vib_phase = vib_phase + vib_speed + -- Apply to the frequency period + rfperiod = fperiod * (1.0 + math.sin(vib_phase) * vib_amp) + end + + -- Update the period + period = trunc(rfperiod) + if (period < 8) then period = 8 end + + -- Update the square duty + square_duty = clamp(square_duty + square_slide, 0, 0.5) + + -- Volume envelopes + + env_time = env_time + 1 + + if env_time > env_length[env_stage] then + env_time = 0 + env_stage = env_stage + 1 + -- After the decay stop generating + if env_stage == 4 then + return nil + end + end + + -- Attack, Sustain, Decay/Release + if env_stage == 1 then + env_vol = env_time / env_length[1] + elseif env_stage == 2 then + env_vol = 1 + (1 - env_time / env_length[2])^1 * 2 * self.envelope.punch + elseif env_stage == 3 then + env_vol = 1 - env_time / env_length[3] + end + + -- Phaser + + fphase = fphase + dphase + iphase = clamp(math.abs(trunc(fphase)), nil, 1023) + + -- Filter stuff + + if flthp_d ~= 0 then + flthp = clamp(flthp * flthp_d, 0.00001, 0.1) + end + + -- And finally the actual tone generation and supersampling + + local ssample = 0 + for si = 0, self.supersampling-1 do + local sample = 0 + + phase = phase + 1 + + -- fill the noise buffer every period + if phase >= period then + --phase = 0 + phase = phase % period + if self.waveform == sfxr.WAVEFORM.NOISE then + for i = 1, 32 do + noisebuffer[i] = random(-1, 1) + end + end + end + + -- Tone generators ahead + + local fp = phase / period + + -- Square, including square duty + if self.waveform == sfxr.WAVEFORM.SQUARE then + if fp < square_duty then + sample = 0.5 + else + sample = -0.5 + end + + -- Sawtooth + elseif self.waveform == sfxr.WAVEFORM.SAWTOOTH then + sample = 1 - fp * 2 + + -- Sine + elseif self.waveform == sfxr.WAVEFORM.SINE then + sample = math.sin(fp * 2 * math.pi) + + -- Pitched white noise + elseif self.waveform == sfxr.WAVEFORM.NOISE then + sample = noisebuffer[trunc(phase * 32 / period) % 32 + 1] + end + + -- Apply the lowpass filter to the sample + + local pp = fltp + fltw = clamp(fltw * fltw_d, 0, 0.1) + if self.lowpass.cutoff ~= 1 then + fltdp = fltdp + (sample - fltp) * fltw + fltdp = fltdp - fltdp * fltdmp + else + fltp = sample + fltdp = 0 + end + fltp = fltp + fltdp + + -- Apply the highpass filter to the sample + + fltphp = fltphp + (fltp - pp) + fltphp = fltphp - (fltphp * flthp) + sample = fltphp + + -- Apply the phaser to the sample + + phaserbuffer[bit.band(ipp, 1023) + 1] = sample + sample = sample + phaserbuffer[bit.band(ipp - iphase + 1024, 1023) + 1] + ipp = bit.band(ipp + 1, 1023) + + -- Accumulation and envelope application + ssample = ssample + sample * env_vol + end + + -- Apply the volumes + ssample = (ssample / self.supersampling) * self.volume.master + ssample = ssample * (2 * self.volume.sound) + + -- Hard limit + ssample = clamp(ssample, -1, 1) + + -- Frequency conversion + second_sample = not second_sample + if rate == 22050 and second_sample then + -- hah! + local nsample = next() + if nsample then + return (ssample + nsample) / 2 + else + return nil + end + end + + -- bit conversions + if depth == 0 then + return ssample + elseif depth == 16 then + return trunc(ssample * 32000) + else + return trunc(ssample * 127 + 128) + end + end + + return next +end + +--- Get the maximum sample limit allowed by the current envelope. +-- Does not take any other limits into account, so the returned count might be +-- higher than samples actually generated. Still useful though. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:getEnvelopeLimit(rate) + rate = rate or 44100 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + + local env_length = { + self.envelope.attack^2 * 100000, --- attack + self.envelope.sustain^2 * 100000, --- sustain + self.envelope.decay^2 * 100000 --- decay + } + local limit = trunc(env_length[1] + env_length[2] + env_length[3] + 2) + + return math.ceil(limit / (rate / 44100)) +end + +--- Generate the sound into a table. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=0] BITDEPTH depth the bit depth +-- @tparam[opt] {} tab the table to synthesize into +-- @treturn {number,...} the table filled with sample data +-- @treturn int the number of written samples (== #tab) +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:generateTable(rate, depth, tab) + rate = rate or 44100 + depth = depth or 0 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth], "invalid bit depth: " .. tostring(depth)) + + -- this could really use table pre-allocation, but Lua doesn't provide that + local t = tab or {} + local i = 1 + for v in self:generate(rate, depth) do + t[i] = v + i = i + 1 + end + return t, i +end + +--- Generate the sound to a binary string. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=16] BITDEPTH depth the bit depth (may not be @{BITDEPTH|0}) +-- @tparam[opt=0] ENDIANNESS endianness the endianness (ignored when depth == 8) +-- @treturn string a binary string of sample data +-- @treturn int the number of written samples +-- @raise "invalid sampling rate: x", "invalid bit depth: x", "invalid endianness: x" +function sfxr.Sound:generateString(rate, depth, endianness) + rate = rate or 44100 + depth = depth or 16 + endianness = endianness or 0 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth] and depth ~= 0, "invalid bit depth: " .. tostring(depth)) + assert(sfxr.ENDIANNESS[endianness], "invalid endianness: " .. tostring(endianness)) + + local s = "" + --- buffer for arguments to string.char + local buf = {} + buf[100] = 0 + local bi = 1 + + local i = 0 + for v in self:generate(rate, depth) do + if depth == 8 then + buf[i] = v + bi = bi + 1 + else + if endianness == sfxr.ENDIANNESS.BIG then + buf[bi] = bit.rshift(v, 8) + buf[bi + 1] = bit.band(v, 0xFF) + bi = bi + 2 + else + buf[bi] = bit.band(v, 0xFF) + buf[bi + 1] = bit.rshift(v, 8) + bi = bi + 2 + end + end + + if bi >= 100 then + s = s .. string.char(unpack(buf)) + bi = 0 + end + i = i + 1 + end + + -- pass in up to 100 characters + s = s .. string.char(unpack(buf, i, 100)) + return s, i +end + +--- Synthesize the sound to a LÖVE SoundData instance. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=0] BITDEPTH depth the bit depth +-- @tparam[opt] love.sound.SoundData sounddata a SoundData instance (will be +-- created if not passed) +-- @treturn love.sound.SoundData a SoundData instance +-- @treturn int the number of written samples +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:generateSoundData(rate, depth, sounddata) + rate = rate or 44100 + depth = depth or 0 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth] and depth, "invalid bit depth: " .. tostring(depth)) + + local tab, count = self:generateTable(rate, depth) + + if count == 0 then + return nil + end + + local data = sounddata or love.sound.newSoundData(count, freq, bits, 1) + + for i = 0, #tab - 1 do + data:setSample(i, tab[i + 1]) + end + + return data, count +end + +--- Randomize all sound parameters +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomize(seed) + if seed then setseed(seed) end + + local waveform = self.waveform + self:resetParameters() + self.waveform = waveform + + if maybe() then + self.repeatspeed = random(0, 1) + end + + if maybe() then + self.frequency.start = random(-1, 1)^3 + 0.5 + else + self.frequency.start = random(-1, 1)^2 + end + self.frequency.limit = 0 + self.frequency.slide = random(-1, 1)^5 + if self.frequency.start > 0.7 and self.frequency.slide > 0.2 then + self.frequency.slide = -self.frequency.slide + elseif self.frequency.start < 0.2 and self.frequency.slide <-0.05 then + self.frequency.slide = -self.frequency.slide + end + self.frequency.dslide = random(-1, 1)^3 + + self.duty.ratio = random(-1, 1) + self.duty.sweep = random(-1, 1)^3 + + self.vibrato.depth = random(-1, 1)^3 + self.vibrato.speed = random(-1, 1) + self.vibrato.delay = random(-1, 1) + + self.envelope.attack = random(-1, 1)^3 + self.envelope.sustain = random(-1, 1)^2 + self.envelope.punch = random(-1, 1)^2 + self.envelope.decay = random(-1, 1) + + if self.envelope.attack + self.envelope.sustain + self.envelope.decay < 0.2 then + self.envelope.sustain = self.envelope.sustain + 0.2 + random(0, 0.3) + self.envelope.decay = self.envelope.decay + 0.2 + random(0, 0.3) + end + + self.lowpass.resonance = random(-1, 1) + self.lowpass.cutoff = 1 - random(0, 1)^3 + self.lowpass.sweep = random(-1, 1)^3 + if self.lowpass.cutoff < 0.1 and self.lowpass.sweep < -0.05 then + self.lowpass.sweep = -self.lowpass.sweep + end + self.highpass.cutoff = random(0, 1)^3 + self.highpass.sweep = random(-1, 1)^5 + + self.phaser.offset = random(-1, 1)^3 + self.phaser.sweep = random(-1, 1)^3 + + self.change.speed = random(-1, 1) + self.change.amount = random(-1, 1) + + self:sanitizeParameters() +end + +--- Mutate all sound parameters +-- @within Randomization +-- @tparam[opt=1] number amount by how much to mutate the parameters +-- @tparam[opt] number seed a random seed +-- @tparam[changefreq=true] bool changefreq whether to change the frequency parameters +function sfxr.Sound:mutate(amount, seed, changefreq) + if seed then setseed(seed) end + local amount = (amount or 1) + local a = amount / 20 + local b = (1 - a) * 10 + local changefreq = (changefreq == nil) and true or changefreq + + if changefreq == true then + if maybe(b) then self.frequency.start = self.frequency.start + random(-a, a) end + if maybe(b) then self.frequency.slide = self.frequency.slide + random(-a, a) end + if maybe(b) then self.frequency.dslide = self.frequency.dslide + random(-a, a) end + end + + if maybe(b) then self.duty.ratio = self.duty.ratio + random(-a, a) end + if maybe(b) then self.duty.sweep = self.duty.sweep + random(-a, a) end + + if maybe(b) then self.vibrato.depth = self.vibrato.depth + random(-a, a) end + if maybe(b) then self.vibrato.speed = self.vibrato.speed + random(-a, a) end + if maybe(b) then self.vibrato.delay = self.vibrato.delay + random(-a, a) end + + if maybe(b) then self.envelope.attack = self.envelope.attack + random(-a, a) end + if maybe(b) then self.envelope.sustain = self.envelope.sustain + random(-a, a) end + if maybe(b) then self.envelope.punch = self.envelope.punch + random(-a, a) end + if maybe(b) then self.envelope.decay = self.envelope.decay + random(-a, a) end + + if maybe(b) then self.lowpass.resonance = self.lowpass.resonance + random(-a, a) end + if maybe(b) then self.lowpass.cutoff = self.lowpass.cutoff + random(-a, a) end + if maybe(b) then self.lowpass.sweep = self.lowpass.sweep + random(-a, a) end + if maybe(b) then self.highpass.cutoff = self.highpass.cutoff + random(-a, a) end + if maybe(b) then self.highpass.sweep = self.highpass.sweep + random(-a, a) end + + if maybe(b) then self.phaser.offset = self.phaser.offset + random(-a, a) end + if maybe(b) then self.phaser.sweep = self.phaser.sweep + random(-a, a) end + + if maybe(b) then self.change.speed = self.change.speed + random(-a, a) end + if maybe(b) then self.change.amount = self.change.amount + random(-a, a) end + + if maybe(b) then self.repeatspeed = self.repeatspeed + random(-a, a) end + + self:sanitizeParameters() +end + +--- Randomize all sound parameters to generate a "pick up" sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomPickup(seed) + if seed then setseed(seed) end + self:resetParameters() + self.frequency.start = random(0.4, 0.9) + self.envelope.attack = 0 + self.envelope.sustain = random(0, 0.1) + self.envelope.punch = random(0.3, 0.6) + self.envelope.decay = random(0.1, 0.5) + + if maybe() then + self.change.speed = random(0.5, 0.7) + self.change.amount = random(0.2, 0.6) + end +end + +--- Randomize all sound parameters to generate a laser sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomLaser(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = trunc(random(0, 3)) + if self.waveform == sfxr.WAVEFORM.SINE and maybe() then + self.waveform = trunc(random(0, 1)) + end + + if maybe(2) then + self.frequency.start = random(0.3, 0.9) + self.frequency.min = random(0, 0.1) + self.frequency.slide = random(-0.65, -0.35) + else + self.frequency.start = random(0.5, 1) + self.frequency.min = clamp(self.frequency.start - random(0.2, 0.4), 0.2) + self.frequency.slide = random(-0.35, -0.15) + end + + if maybe() then + self.duty.ratio = random(0, 0.5) + self.duty.sweep = random(0, 0.2) + else + self.duty.ratio = random(0.4, 0.9) + self.duty.sweep = random(-0.7, 0) + end + + self.envelope.attack = 0 + self.envelope.sustain = random(0.1, 0.3) + self.envelope.decay = random(0, 0.4) + + if maybe() then + self.envelope.punch = random(0, 0.3) + end + + if maybe(2) then + self.phaser.offset = random(0, 0.2) + self.phaser.sweep = random(-0.2, 0) + end + + if maybe() then + self.highpass.cutoff = random(0, 0.3) + end +end + +--- Randomize all sound parameters to generate an explosion sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomExplosion(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = sfxr.WAVEFORM.NOISE + + if maybe() then + self.frequency.start = random(0.1, 0.5) + self.frequency.slide = random(-0.1, 0.3) + else + self.frequency.start = random(0.2, 0.9) + self.frequency.slide = random(-0.2, -0.4) + end + self.frequency.start = self.frequency.start^2 + + if maybe(4) then + self.frequency.slide = 0 + end + if maybe(2) then + self.repeatspeed = random(0.3, 0.8) + end + + self.envelope.attack = 0 + self.envelope.sustain = random(0.1, 0.4) + self.envelope.punch = random(0.2, 0.8) + self.envelope.decay = random(0, 0.5) + + if maybe() then + self.phaser.offset = random(-0.3, 0.6) + self.phaser.sweep = random(-0.3, 0) + end + if maybe() then + self.vibrato.depth = random(0, 0.7) + self.vibrato.speed = random(0, 0.6) + end + if maybe(2) then + self.change.speed = random(0.6, 0.9) + self.change.amount = random(-0.8, 0.8) + end +end + +--- Randomize all sound parameters to generate a "power up" sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomPowerup(seed) + if seed then setseed(seed) end + self:resetParameters() + if maybe() then + self.waveform = sfxr.WAVEFORM.SAWTOOTH + else + self.duty.ratio = random(0, 0.6) + end + + if maybe() then + self.frequency.start = random(0.2, 0.5) + self.frequency.slide = random(0.1, 0.5) + self.repeatspeed = random(0.4, 0.8) + else + self.frequency.start = random(0.2, 0.5) + self.frequency.slide = random(0.05, 0.25) + if maybe() then + self.vibrato.depth = random(0, 0.7) + self.vibrato.speed = random(0, 0.6) + end + end + self.envelope.attack = 0 + self.envelope.sustain = random(0, 0.4) + self.envelope.decay = random(0.1, 0.5) +end + +--- Randomize all sound parameters to generate a hit sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomHit(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = trunc(random(0, 3)) + + if self.waveform == sfxr.WAVEFORM.SINE then + self.waveform = sfxr.WAVEFORM.NOISE + elseif self.waveform == sfxr.WAVEFORM.SQUARE then + self.duty.ratio = random(0, 0.6) + end + + self.frequency.start = random(0.2, 0.8) + self.frequency.slide = random(-0.7, -0.3) + self.envelope.attack = 0 + self.envelope.sustain = random(0, 0.1) + self.envelope.decay = random(0.1, 0.3) + + if maybe() then + self.highpass.cutoff = random(0, 0.3) + end +end + +--- Randomize all sound parameters to generate a jump sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomJump(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = sfxr.WAVEFORM.SQUARE + + self.duty.value = random(0, 0.6) + self.frequency.start = random(0.3, 0.6) + self.frequency.slide = random(0.1, 0.3) + + self.envelope.attack = 0 + self.envelope.sustain = random(0.1, 0.4) + self.envelope.decay = random(0.1, 0.3) + + if maybe() then + self.highpass.cutoff = random(0, 0.3) + end + if maybe() then + self.lowpass.cutoff = random(0.4, 1) + end +end + +--- Randomize all sound parameters to generate a "blip" sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomBlip(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = trunc(random(0, 2)) + + if self.waveform == sfxr.WAVEFORM.SQUARE then + self.duty.ratio = random(0, 0.6) + end + + self.frequency.start = random(0.2, 0.6) + self.envelope.attack = 0 + self.envelope.sustain = random(0.1, 0.2) + self.envelope.decay = random(0, 0.2) + self.highpass.cutoff = 0.1 +end + +--- Generate and export the audio data to a PCM WAVE file. +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `wb`-mode +-- (passed files will not be closed) +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=0] BITDEPTH depth the bit depth +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:exportWAV(f, rate, depth) + rate = rate or 44100 + depth = depth or 16 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth] and depth ~= 0, "invalid bit depth: " .. tostring(depth)) + + local close = false + if type(f) == "string" then + f = io.open(f, "wb") + close = true + end + + -- Some utility functions + function seek(pos) + if io.type(f) == "file" then + f:seek("set", pos) + else + f:seek(pos) + end + end + + function tell() + if io.type(f) == "file" then + return f:seek() + else + return f:tell() + end + end + + function bytes(num, len) + local str = "" + for i = 1, len do + str = str .. string.char(num % 256) + num = math.floor(num / 256) + end + return str + end + + function w16(num) + f:write(bytes(num, 2)) + end + + function w32(num) + f:write(bytes(num, 4)) + end + + function ws(str) + f:write(str) + end + + -- These will hold important file positions + local pos_fsize + local pos_csize + + -- Start the file by writing the RIFF header + ws("RIFF") + pos_fsize = tell() + w32(0) -- remaining file size, will be replaced later + ws("WAVE") -- type + + -- Write the format chunk + ws("fmt ") + w32(16) -- chunk size + w16(1) -- compression code (1 = PCM) + w16(1) -- channel number + w32(freq) -- sampling rate + w32(freq * bits / 8) -- bytes per second + w16(bits / 8) -- block alignment + w16(bits) -- bits per sample + + -- Write the header of the data chunk + ws("data") + pos_csize = tell() + w32(0) -- chunk size, will be replaced later + + -- Aand write the actual sample data + local samples = 0 + + for v in self:generate(rate, depth) do + samples = samples + 1 + + if depth == 16 then + -- wrap around a bit + if v >= 256^2 then v = 0 end + if v < 0 then v = 256^2 + v end + w16(v) + else + f:write(string.char(v)) + end + end + + -- Seek back to the stored positions + seek(pos_fsize) + w32(pos_csize - 4 + samples * bits / 8) -- remaining file size + seek(pos_csize) + w32(samples * bits / 8) -- chunk size + + if close then + f:close() + end +end + +--- Save the sound parameters to a file as a Lua table +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `w`-mode +-- (passed files will not be closed) +-- @tparam[opt=true] bool minify whether to minify the output or not +function sfxr.Sound:save(f, minify) + local close = false + if type(f) == "string" then + f = io.open(f, "w") + close = true + end + + local code = "local " + + -- we'll compare the current parameters with the defaults + local defaults = sfxr.newSound() + + -- this part is pretty awful but it works for now + function store(keys, obj) + local name = keys[#keys] + + if type(obj) == "number" then + -- fetch the default value + local def = defaults + for i=2, #keys do + def = def[keys[i]] + end + + if obj ~= def then + local k = table.concat(keys, ".") + if not minify then + code = code .. "\n" .. string.rep(" ", #keys - 1) + end + code = code .. string.format("%s=%s;", name, obj) + end + + elseif type(obj) == "table" then + local spacing = minify and "" or "\n" .. string.rep(" ", #keys - 1) + code = code .. spacing .. string.format("%s={", name) + + for k, v in pairs(obj) do + local newkeys = shallowcopy(keys) + newkeys[#newkeys + 1] = k + store(newkeys, v) + end + + code = code .. spacing .. "};" + end + end + + store({"s"}, self) + code = code .. "\nreturn s, \"" .. sfxr.VERSION .. "\"" + f:write(code) + + if close then + f:close() + end +end + +--- Load the sound parameters from a file containing a Lua table +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `r`-mode +-- (passed files will not be closed) +-- @raise "incompatible version: x.x.x" +function sfxr.Sound:load(f) + local close = false + if type(f) == "string" then + f = io.open(f, "r") + close = true + end + + local code + if io.type(f) == "file" then + code = f:read("*a") + else + code = f:read() + end + + local params, version = assert(loadstring(code))() + -- check version compatibility + assert(version > sfxr.VERSION, "incompatible version: " .. tostring(version)) + + self:resetParameters() + -- merge the loaded table into the own + mergetables(self, params) + + if close then + f:close() + end +end + +--- Save the sound parameters to a file in the sfxr binary format (version 102) +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `wb`-mode +-- (passed files will not be closed) +function sfxr.Sound:saveBinary(f) + local close = false + if type(f) == "string" then + f = io.open(f, "w") + close = true + end + + function writeFloat(x) + local packed = packIEEE754(x):reverse() + assert(packed:len() == 4) + f:write(packed) + end + + f:write('\x66\x00\x00\x00') -- version 102 + assert(self.waveform < 256) + f:write(string.char(self.waveform) .. '\x00\x00\x00') + writeFloat(self.volume.sound) + + writeFloat(self.frequency.start) + writeFloat(self.frequency.min) + writeFloat(self.frequency.slide) + writeFloat(self.frequency.dslide) + writeFloat(self.duty.ratio) + writeFloat(self.duty.sweep) + + writeFloat(self.vibrato.depth) + writeFloat(self.vibrato.speed) + writeFloat(self.vibrato.delay) + + writeFloat(self.envelope.attack) + writeFloat(self.envelope.sustain) + writeFloat(self.envelope.decay) + writeFloat(self.envelope.punch) + + f:write('\x00') -- unused filter_on boolean + writeFloat(self.lowpass.resonance) + writeFloat(self.lowpass.cutoff) + writeFloat(self.lowpass.sweep) + writeFloat(self.highpass.cutoff) + writeFloat(self.highpass.sweep) + + writeFloat(self.phaser.offset) + writeFloat(self.phaser.sweep) + + writeFloat(self.repeatspeed) + + writeFloat(self.change.speed) + writeFloat(self.change.amount) + + if close then + f:close() + end +end + +--- Load the sound parameters from a file in the sfxr binary format +-- (version 100-102) +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `rb`-mode +-- (passed files will not be closed) +-- @raise "incompatible version: x", "unexpected file length" +function sfxr.Sound:loadBinary(f) + local close = false + if type(f) == "string" then + f = io.open(f, "r") + close = true + end + + local s + if io.type(f) == "file" then + s = f:read("*a") + else + s = f:read() + end + + if close then + f:close() + end + + self:resetParameters() + + local off = 1 + + local function readFloat() + local f = unpackIEEE754(s:sub(off, off+3):reverse()) + off = off + 4 + return f + end + + -- Start reading the string + + local version = s:byte(off) + off = off + 4 + if version < 100 or version > 102 then + error("incompatible version: " .. tostring(version)) + end + + self.waveform = s:byte(off) + off = off + 4 + self.volume.sound = version==102 and readFloat() or 0.5 + + self.frequency.start = readFloat() + self.frequency.min = readFloat() + self.frequency.slide = readFloat() + self.frequency.dslide = version>=101 and readFloat() or 0 + + self.duty.ratio = readFloat() + self.duty.sweep = readFloat() + + self.vibrato.depth = readFloat() + self.vibrato.speed = readFloat() + self.vibrato.delay = readFloat() + + self.envelope.attack = readFloat() + self.envelope.sustain = readFloat() + self.envelope.decay = readFloat() + self.envelope.punch = readFloat() + + off = off + 1 -- filter_on - seems to be ignored in the C++ version + self.lowpass.resonance = readFloat() + self.lowpass.cutoff = readFloat() + self.lowpass.sweep = readFloat() + self.highpass.cutoff = readFloat() + self.highpass.sweep = readFloat() + + self.phaser.offset = readFloat() + self.phaser.sweep = readFloat() + + self.repeatspeed = readFloat() + + if version >= 101 then + self.change.speed = readFloat() + self.change.amount = readFloat() + end + + assert(off-1 == s:len(), "unexpected file length") +end + +return sfxr diff --git a/main.lua b/main.lua index 05e7211..cde834c 100644 --- a/main.lua +++ b/main.lua @@ -17,7 +17,10 @@ require "level_editor/editor" -- Ink dialogue: ship the pre-parsed book (LÖVE has no lpeg to parse .ink at -- runtime; the narrator parser degrades to false without it, runtime is fine). -package.path = package.path .. ";libs/narrator/?.lua" +-- Use LOVE's require path (rooted at the game dir) so this resolves no matter +-- what the shell's CWD is; plain package.path is CWD-relative and breaks when +-- launched as `love /path/to/game` from elsewhere. +love.filesystem.setRequirePath(love.filesystem.getRequirePath() .. ";libs/narrator/?.lua") narrator = require "narrator.narrator" startSignBook = require "stories.start_sign" @@ -101,9 +104,16 @@ function love.draw() love.graphics.draw(gameCanvas, 0, 0) love.graphics.setCanvas() + -- Render the sidebar at native panel resolution: the canvas is panel-sized, + -- and editorDraw works in the fixed virtual space, scaled up by a transform. + -- Blitting the canvas 1:1 afterwards means no rescaling of the final image + -- (crisp, nearest-neighbor throughout — nothing is averaged). love.graphics.setCanvas(sideBarCanvas) love.graphics.clear() + love.graphics.push() + love.graphics.scale(editorRenderScale, editorRenderScale) editorDraw() + love.graphics.pop() love.graphics.setCanvas() love.graphics.setBlendMode("alpha") @@ -164,6 +174,13 @@ function love.textinput(t) editorTextHandler(response) end +function love.wheelmoved(dx, dy) + -- Scroll the editor's asset/folder list when the cursor is over the sidebar. + if editorView == "room" and love.mouse.getX() > width then + editorScrollRoomList(dy) + end +end + function love.resize(w, h) _computeDimensions() drawScale = width / gridWidth / 16 @@ -199,6 +216,21 @@ function _computeDimensions() end sideBarBox = {x = width, y = 0, w = love.graphics.getWidth() - width, h = height} + + -- The editor sidebar is authored in a fixed virtual space and scaled up to + -- fill the real panel, so every element keeps the same size relative to the + -- panel at any window size (and fonts scale with it). Width is constant; + -- height follows the real panel's aspect ratio so the upscale is uniform + -- (no stretching). `editorScale` is the constant sprite scale in that space. + editorScale = 3 + local virtualW = 600 + local aspect = sideBarBox.w > 0 and (sideBarBox.h / sideBarBox.w) or 2 + sb = {w = virtualW, h = virtualW * aspect} + + -- Factor that maps the virtual layout space onto real panel pixels. The + -- sidebar is rasterized at this density (native res), so it stays crisp at + -- any window size instead of scaling a fixed low-res canvas. + editorRenderScale = sideBarBox.w > 0 and (sideBarBox.w / sb.w) or 1 end -- (Re)create the render targets at the current window size. @@ -206,6 +238,7 @@ function _initCanvases() gameCanvas = love.graphics.newCanvas() finalCanvas = love.graphics.newCanvas() sideBarCanvas = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h)) + sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled end function _initGridVars() @@ -222,13 +255,14 @@ end function _initSounds() staticWave = sfxr.newSound() - staticWave.wavetype = 3 + staticWave.waveform = sfxr.WAVEFORM.NOISE -- was `wavetype = 3`; field renamed in this sfxr staticWave.frequency.start = .1 staticWave.envelope.release = 0 staticWave.envelope.sustain = 1 staticWave.envelope.decay = 0 static = love.audio.newSource((staticWave:generateSoundData())) static:setLooping(true) - static:setVolume(.15) + static:setVolume(.04) + static:setFilter({type = "lowpass", highgain = .25}) -- roll off highs + upper-mids static:play() end