From cfd9ed870852d6bf83b7e7f5904203cf3bfcd1c8 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Wed, 17 Sep 2025 18:08:26 +0300 Subject: [PATCH] Lua works; narrator works --- CMakeLists.txt | 12 +- Game.cpp | 6 +- assets/blender/altar.blend | 4 +- lua-scripts/CMakeLists.txt | 24 + lua-scripts/data.lua | 66 +- lua-scripts/narrator/annotations.lua | 37 + lua-scripts/narrator/enums.lua | 32 + lua-scripts/narrator/libs/classic.lua | 68 ++ lua-scripts/narrator/libs/lume.lua | 780 +++++++++++++++ lua-scripts/narrator/list/mt.lua | 401 ++++++++ lua-scripts/narrator/narrator.lua | 150 +++ lua-scripts/narrator/parser.lua | 789 ++++++++++++++++ lua-scripts/narrator/story.lua | 1253 +++++++++++++++++++++++++ lua-scripts/stories/debug.ink | 0 lua-scripts/stories/game.ink | 19 + lua-scripts/stories/game.lua | 1 + src/gamedata/CharacterModule.cpp | 112 +-- src/gamedata/GUIModule.cpp | 41 +- src/gamedata/GUIModule.h | 2 + src/gamedata/LuaData.cpp | 120 ++- src/gamedata/TerrainModule.cpp | 23 +- src/lua/CMakeLists.txt | 19 +- src/lua/lua.c | 694 ++++++++++++++ world_map.kra | 4 +- 24 files changed, 4557 insertions(+), 100 deletions(-) create mode 100644 lua-scripts/CMakeLists.txt create mode 100644 lua-scripts/narrator/annotations.lua create mode 100644 lua-scripts/narrator/enums.lua create mode 100755 lua-scripts/narrator/libs/classic.lua create mode 100755 lua-scripts/narrator/libs/lume.lua create mode 100644 lua-scripts/narrator/list/mt.lua create mode 100644 lua-scripts/narrator/narrator.lua create mode 100644 lua-scripts/narrator/parser.lua create mode 100644 lua-scripts/narrator/story.lua create mode 100644 lua-scripts/stories/debug.ink create mode 100644 lua-scripts/stories/game.ink create mode 100644 lua-scripts/stories/game.lua create mode 100644 src/lua/lua.c diff --git a/CMakeLists.txt b/CMakeLists.txt index d754fef..7a49415 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,6 +72,7 @@ add_subdirectory(src/miniaudio) add_subdirectory(src/sound) add_subdirectory(audio/gui) add_subdirectory(tests) +add_subdirectory(lua-scripts) # add the source files as usual add_executable(0_Bootstrap Bootstrap.cpp) @@ -192,17 +193,6 @@ if(OGRE_STATIC) target_link_libraries(Editor fix::assimp pugixml) endif() -file(GLOB LUA_SCRIPTS_SRC ${CMAKE_SOURCE_DIR}/lua-scripts/*.lua) -set(LUA_SCRIPTS_OUTPUT) -foreach(LUA_SCRIPT_FILE ${LUA_SCRIPTS_SRC}) - get_filename_component(FILE_NAME ${LUA_SCRIPT_FILE} NAME_WE) - set(LUA_SCRIPT_OUTPUT_FILE ${CMAKE_BINARY_DIR}/lua-scripts/${FILE_NAME}.lua) - add_custom_command(OUTPUT ${LUA_SCRIPT_OUTPUT_FILE} - COMMAND ${CMAKE_COMMAND} -E copy ${LUA_SCRIPT_FILE} ${LUA_SCRIPT_OUTPUT_FILE} - DEPENDS ${LUA_SCRIPT_FILE}) - list(APPEND LUA_SCRIPTS_OUTPUT ${LUA_SCRIPT_OUTPUT_FILE}) -endforeach() -add_custom_target(stage_lua_scripts ALL DEPENDS ${LUA_SCRIPTS_OUTPUT}) add_dependencies(TerrainTest stage_lua_scripts stage_files) add_custom_command( diff --git a/Game.cpp b/Game.cpp index e470414..b16f54b 100644 --- a/Game.cpp +++ b/Game.cpp @@ -210,7 +210,6 @@ public: bool updated = false; if (isGuiEnabled()) return false; - std::cout << "GUI not enabled\n"; if (evt.keysym.sym == OgreBites::SDLK_ESCAPE) { OgreAssert(ECS::get().has(), ""); setGuiEnabled(true); @@ -336,6 +335,11 @@ public: { Ogre::ResourceGroupManager::getSingleton().createResourceGroup( "Water", true); + Ogre::ResourceGroupManager::getSingleton().createResourceGroup( + "LuaScripts", false); + Ogre::ResourceGroupManager::getSingleton().addResourceLocation( + "./lua-scripts", "FileSystem", "LuaScripts", true, + true); OgreBites::ApplicationContext::locateResources(); } void loadResources() override diff --git a/assets/blender/altar.blend b/assets/blender/altar.blend index 4d07a87..5f63900 100644 --- a/assets/blender/altar.blend +++ b/assets/blender/altar.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82a280805d9b958cbf294c68970c1eb65f01a4087b902bb800e71a02192b75a6 -size 190891 +oid sha256:654a3a0dbbc23614a966123a66c62ad62d84faf35298fb3726adab9b61b6b848 +size 289327 diff --git a/lua-scripts/CMakeLists.txt b/lua-scripts/CMakeLists.txt new file mode 100644 index 0000000..14eddb7 --- /dev/null +++ b/lua-scripts/CMakeLists.txt @@ -0,0 +1,24 @@ +project(lua-scripts) +set(LUA_SCRIPTS_SRC +data.lua +) +set(LUA_SCRIPTS_OUTPUT) +set(LUA_PACKAGES narrator stories) +foreach(LUA_SCRIPT_FILE ${LUA_SCRIPTS_SRC}) + get_filename_component(FILE_NAME ${LUA_SCRIPT_FILE} NAME_WE) + set(LUA_SCRIPT_OUTPUT_FILE ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}.lua) + add_custom_command(OUTPUT ${LUA_SCRIPT_OUTPUT_FILE} + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/${LUA_SCRIPT_FILE} ${LUA_SCRIPT_OUTPUT_FILE} + DEPENDS ${LUA_SCRIPT_FILE}) + list(APPEND LUA_SCRIPTS_OUTPUT ${LUA_SCRIPT_OUTPUT_FILE}) +endforeach() +set(LUA_PACKAGES_OUTPUT) +foreach(LUA_PACKAGE ${LUA_PACKAGES}) + add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${LUA_PACKAGE} + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/${LUA_PACKAGE} + ${CMAKE_CURRENT_BINARY_DIR}/${LUA_PACKAGE} + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${LUA_PACKAGE}) + list(APPEND LUA_PACKAGES_OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${LUA_PACKAGE}) +endforeach() +add_custom_target(stage_lua_scripts ALL DEPENDS ${LUA_SCRIPTS_OUTPUT} ${LUA_PACKAGES_OUTPUT}) \ No newline at end of file diff --git a/lua-scripts/data.lua b/lua-scripts/data.lua index de7500e..3012527 100644 --- a/lua-scripts/data.lua +++ b/lua-scripts/data.lua @@ -36,6 +36,7 @@ function foo() end v = Vector3(0, 1, 2) end +--[[ narration = { position = 1, narration_start = { @@ -59,15 +60,74 @@ dropped you into the sea. Last thing you heard before you hit the water was happ return ret end, } +]]-- +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() + +function dump(o) + if type(o) == 'table' then + local s = '{ ' + for k,v in pairs(o) do + if type(k) ~= 'number' then k = '"'..k..'"' end + s = s .. '['..k..'] = ' .. dump(v) .. ',' + end + return s .. '} ' + else + return tostring(o) + end +end + +function _narration() + local ret = "" + local choices = {} + if story:can_continue() then + local paragraphs = story:continue() + for _, paragraph in ipairs(paragraphs) do + local text = paragraph.text + if paragraph.tags then + text = text .. ' #' .. table.concat(paragraph.tags, ' #') + end + ret = ret .. text + end + if story:can_choose() then + local ch = story:get_choices() + for i, choice in ipairs(ch) do + table.insert(choices, choice.text) + print(i, dump(choice)) + end + end + end + if (#choices > 0) then + print("choices!!!") + narrate(ret, choices) + else + narrate(ret) + end +end + setup_handler(function(event) print(event) if event == "startup" then main_menu() elseif event == "narration_progress" then - narrate(narration:progress()) - + _narration() + elseif event == "narration_answered" then + local answer = narration_get_answer() + story:choose(answer) + _narration() elseif event == "new_game" then - narrate(narration:progress()) + local ret = "" + story = narrator.init_story(book) + story:begin() + _narration() end end) diff --git a/lua-scripts/narrator/annotations.lua b/lua-scripts/narrator/annotations.lua new file mode 100644 index 0000000..f82f3a6 --- /dev/null +++ b/lua-scripts/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/lua-scripts/narrator/enums.lua b/lua-scripts/narrator/enums.lua new file mode 100644 index 0000000..5fc62df --- /dev/null +++ b/lua-scripts/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/lua-scripts/narrator/libs/classic.lua b/lua-scripts/narrator/libs/classic.lua new file mode 100755 index 0000000..cbd6f81 --- /dev/null +++ b/lua-scripts/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/lua-scripts/narrator/libs/lume.lua b/lua-scripts/narrator/libs/lume.lua new file mode 100755 index 0000000..2157891 --- /dev/null +++ b/lua-scripts/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/lua-scripts/narrator/list/mt.lua b/lua-scripts/narrator/list/mt.lua new file mode 100644 index 0000000..27d90f7 --- /dev/null +++ b/lua-scripts/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/lua-scripts/narrator/narrator.lua b/lua-scripts/narrator/narrator.lua new file mode 100644 index 0000000..7c037d8 --- /dev/null +++ b/lua-scripts/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/lua-scripts/narrator/parser.lua b/lua-scripts/narrator/parser.lua new file mode 100644 index 0000000..5d01d76 --- /dev/null +++ b/lua-scripts/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/lua-scripts/narrator/story.lua b/lua-scripts/narrator/story.lua new file mode 100644 index 0000000..ce109fd --- /dev/null +++ b/lua-scripts/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/lua-scripts/stories/debug.ink b/lua-scripts/stories/debug.ink new file mode 100644 index 0000000..e69de29 diff --git a/lua-scripts/stories/game.ink b/lua-scripts/stories/game.ink new file mode 100644 index 0000000..7f05169 --- /dev/null +++ b/lua-scripts/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/lua-scripts/stories/game.lua b/lua-scripts/stories/game.lua new file mode 100644 index 0000000..37771f9 --- /dev/null +++ b/lua-scripts/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/src/gamedata/CharacterModule.cpp b/src/gamedata/CharacterModule.cpp index a181152..b5c99ef 100644 --- a/src/gamedata/CharacterModule.cpp +++ b/src/gamedata/CharacterModule.cpp @@ -257,11 +257,11 @@ CharacterModule::CharacterModule(flecs::world &ecs) if (anim.currentAnim == AnimationControl::ANIM_SWIMMING) { float h = Ogre::Math::Clamp( - 0.2f - ch.mBodyNode->getPosition().y, + 0.0f - ch.mBodyNode->getPosition().y, 0.0f, 2000.0f); - if (h > 0.2 && h < 2.0f) - gr.gvelocity.y += 1.2 * (h + 1.0f) * h * - eng.delta; + if (h > 0.05 && h < 2.0f) + gr.gvelocity.y += 0.1f * (h + 1.0f) * + h * eng.delta; } }); ecs.system( @@ -279,7 +279,7 @@ CharacterModule::CharacterModule(flecs::world &ecs) Ogre::Vector3 pos = ch.mBodyNode->getPosition(); Ogre::Vector3 boneMotion = ch.mBoneMotion; v.velocity = rot * boneMotion / eng.delta; - if (eng.startupDelay < 0.0f) + if (eng.startupDelay <= 0.0f) v.velocity += v.gvelocity; v.velocity.y = Ogre::Math::Clamp(v.velocity.y, -10.5f, 1000000.0f); @@ -424,6 +424,57 @@ CharacterModule::CharacterModule(flecs::world &ecs) } } }); +#define TURN_SPEED 500.0f // character turning in degrees per second + ecs.system("UpdateBody") + .kind(flecs::OnUpdate) + .with() + .with() + .each([](flecs::entity e, const Input &input, + const Camera &camera, CharacterBase &ch) { + ch.mGoalDirection = Ogre::Vector3::ZERO; + float delta = e.world().delta_time(); + if (!input.motion.zeroLength()) { + // calculate actually goal direction in world based on player's key directions + ch.mGoalDirection += + input.motion.z * + camera.mCameraNode->getOrientation() + .zAxis(); + ch.mGoalDirection += + input.motion.x * + camera.mCameraNode->getOrientation() + .xAxis(); + ch.mGoalDirection.y = 0; + ch.mGoalDirection.normalise(); + + Ogre::Quaternion toGoal = + ch.mBodyNode->getOrientation() + .zAxis() + .getRotationTo( + ch.mGoalDirection); + // calculate how much the character has to turn to face goal direction + Ogre::Real yawToGoal = + toGoal.getYaw().valueDegrees(); + // this is how much the character CAN turn this frame + Ogre::Real yawAtSpeed = + yawToGoal / Ogre::Math::Abs(yawToGoal) * + delta * TURN_SPEED; + // reduce "turnability" if we're in midair + // if (mBaseAnimID == ANIM_JUMP_LOOP) yawAtSpeed *= 0.2f; + if (yawToGoal < 0) + yawToGoal = std::min( + 0, + std::max( + yawToGoal, + yawAtSpeed)); //yawToGoal = Math::Clamp(yawToGoal, yawAtSpeed, 0); + else if (yawToGoal > 0) + yawToGoal = std::max( + 0, + std::min( + yawToGoal, + yawAtSpeed)); //yawToGoal = Math::Clamp(yawToGoal, 0, yawAtSpeed); + ch.mBodyNode->yaw(Ogre::Degree(yawToGoal)); + } + }); ecs.system("UpdateCharacterBase") .kind(flecs::OnUpdate) @@ -562,57 +613,6 @@ CharacterModule::CharacterModule(flecs::world &ecs) Ogre::Node::TS_PARENT); } }); -#define TURN_SPEED 500.0f // character turning in degrees per second - ecs.system("UpdateBody") - .kind(flecs::OnUpdate) - .with() - .with() - .each([](flecs::entity e, const Input &input, - const Camera &camera, CharacterBase &ch) { - ch.mGoalDirection = Ogre::Vector3::ZERO; - float delta = e.world().delta_time(); - if (!input.motion.zeroLength()) { - // calculate actually goal direction in world based on player's key directions - ch.mGoalDirection += - input.motion.z * - camera.mCameraNode->getOrientation() - .zAxis(); - ch.mGoalDirection += - input.motion.x * - camera.mCameraNode->getOrientation() - .xAxis(); - ch.mGoalDirection.y = 0; - ch.mGoalDirection.normalise(); - - Ogre::Quaternion toGoal = - ch.mBodyNode->getOrientation() - .zAxis() - .getRotationTo( - ch.mGoalDirection); - // calculate how much the character has to turn to face goal direction - Ogre::Real yawToGoal = - toGoal.getYaw().valueDegrees(); - // this is how much the character CAN turn this frame - Ogre::Real yawAtSpeed = - yawToGoal / Ogre::Math::Abs(yawToGoal) * - delta * TURN_SPEED; - // reduce "turnability" if we're in midair - // if (mBaseAnimID == ANIM_JUMP_LOOP) yawAtSpeed *= 0.2f; - if (yawToGoal < 0) - yawToGoal = std::min( - 0, - std::max( - yawToGoal, - yawAtSpeed)); //yawToGoal = Math::Clamp(yawToGoal, yawAtSpeed, 0); - else if (yawToGoal > 0) - yawToGoal = std::max( - 0, - std::min( - yawToGoal, - yawAtSpeed)); //yawToGoal = Math::Clamp(yawToGoal, 0, yawAtSpeed); - ch.mBodyNode->yaw(Ogre::Degree(yawToGoal)); - } - }); class ClosestNotMeRayResultCallback : public btCollisionWorld::ClosestRayResultCallback { btCollisionObject *mMe; diff --git a/src/gamedata/GUIModule.cpp b/src/gamedata/GUIModule.cpp index d3bdab9..35acf4a 100644 --- a/src/gamedata/GUIModule.cpp +++ b/src/gamedata/GUIModule.cpp @@ -243,12 +243,39 @@ struct GUIListener : public Ogre::RenderTargetListener { "%s", ECS::get() .get() .narrationText.c_str()); - ImGui::SetCursorScreenPos(p); - if (ImGui::InvisibleButton( - "Background", - ImGui::GetWindowSize())) - ECS::get().mLua->call_handler( - "narration_progress"); + if (ECS::get().get().choices.size() == 0) { + ImGui::SetCursorScreenPos(p); + if (ImGui::InvisibleButton( + "Background", + ImGui::GetWindowSize())) + ECS::get().mLua->call_handler( + "narration_progress"); + } else { + int i; + for (i = 0; i < ECS::get() + .get() + .choices.size(); + i++) { + if (ImGui::Button( + ECS::get() + .get() + .choices[i] + .c_str())) { + ECS::get() + .get_mut() + .narration_answer = + i + 1; + std::cout << "answer: " + << i + 1 + << std::endl; + ECS::modified(); + ECS::get() + .mLua + ->call_handler( + "narration_answered"); + } + } + } ImGui::Spacing(); ImGui::PopFont(); ImGui::End(); @@ -448,7 +475,7 @@ GUIModule::GUIModule(flecs::world &ecs) priv.mGuiOverlay = nullptr; }) .add(flecs::Singleton); - ecs.set({ false, true, false, false, false }); + ecs.set({ false, true, false, false, false, "", {}, -1 }); ecs.set({ nullptr, {}, nullptr }); ui_wait = ecs.system("SetupGUI") diff --git a/src/gamedata/GUIModule.h b/src/gamedata/GUIModule.h index 14b0fd9..8b0dbc5 100644 --- a/src/gamedata/GUIModule.h +++ b/src/gamedata/GUIModule.h @@ -13,6 +13,8 @@ struct GUI { bool narrationBox; bool mainMenu; Ogre::String narrationText; + std::vector choices; + int narration_answer; static void setWindowGrab(bool g = true) { ECS::GUI &gui = ECS::get().get_mut(); diff --git a/src/gamedata/LuaData.cpp b/src/gamedata/LuaData.cpp index 72fd8ca..7e70968 100644 --- a/src/gamedata/LuaData.cpp +++ b/src/gamedata/LuaData.cpp @@ -1,7 +1,12 @@ +#include #include "GameData.h" #include "Components.h" #include "GUIModule.h" #include "LuaData.h" + +extern "C" { +int luaopen_lpeg(lua_State *L); +} namespace ECS { @@ -23,11 +28,100 @@ int LuaData::call_handler(const Ogre::String &event) } return 0; } + +int luaLibraryLoader(lua_State *L) +{ + int i; + if (!lua_isstring(L, 1)) { + luaL_error( + L, + "luaLibraryLoader: Expected string for first parameter"); + } + + std::string libraryFile = lua_tostring(L, 1); + std::cout << libraryFile << std::endl; + // In order to be compatible with the normal Lua file loader, + // translate '.' to the file system seperator character. + // In this case (An ogre resource) '/' + while (libraryFile.find('.') != std::string::npos) + libraryFile.replace(libraryFile.find('.'), 1, "/"); + + libraryFile += ".lua"; + std::cout << libraryFile << std::endl; + Ogre::StringVectorPtr scripts = + Ogre::ResourceGroupManager::getSingleton().listResourceNames( + "LuaScripts", false); + std::vector &strings = *scripts; + for (i = 0; i < strings.size(); i++) + std::cout << strings[i] << std::endl; + + if (0 && !Ogre::ResourceGroupManager::getSingleton() + .resourceExistsInAnyGroup(libraryFile)) { + // Could not find the file. + std::string errMessage = "\n no file '" + libraryFile + + "' found in Ogre resource archives."; + lua_pushstring(L, errMessage.c_str()); + } else { + Ogre::DataStreamList streams = + Ogre::ResourceGroupManager::getSingleton().openResources( + "*.lua", "LuaScripts"); + Ogre::DataStreamPtr stream = + Ogre::ResourceGroupManager::getSingleton().openResource( + libraryFile, "LuaScripts"); + Ogre::String script = stream->getAsString(); + if (luaL_loadbuffer(L, script.c_str(), script.length(), + libraryFile.c_str())) { + luaL_error( + L, + "Error loading library '%s' from resource archive.\n%s", + libraryFile.c_str(), lua_tostring(L, -1)); + } + } + return 1; +} + +static void installLibraryLoader(lua_State *L) +{ + // Insert the c++ func 'luaLibraryLoader' into package.loaders. + // Inserted at the start of the table in order to take precedence. + lua_getglobal(L, "table"); + lua_getfield(L, -1, "insert"); + lua_remove(L, -2); // table + lua_getglobal(L, "package"); + lua_getfield(L, -1, "searchers"); + lua_remove(L, -2); // package + lua_pushnumber(L, 1); // index where to insert into loaders table + lua_pushcfunction(L, luaLibraryLoader); + if (lua_pcall(L, 3, 0, 0)) + Ogre::LogManager::getSingleton().stream() << lua_tostring(L, 1); +} + LuaData::LuaData() : L(luaL_newstate()) { luaopen_base(L); + luaopen_table(L); luaopen_package(L); + luaL_requiref(L, "table", luaopen_table, 1); + lua_pop(L, 1); + luaL_requiref(L, "math", luaopen_math, 1); + lua_pop(L, 1); + luaL_requiref(L, "package", luaopen_package, 1); + lua_pop(L, 1); + luaL_requiref(L, "string", luaopen_string, 1); + lua_pop(L, 1); + luaL_requiref(L, "io", luaopen_io, 1); + lua_pop(L, 1); + luaL_requiref(L, "lpeg", luaopen_lpeg, 1); + lua_pop(L, 1); +#if 0 + luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_PRELOAD_TABLE); + lua_pushcfunction(L, luaopen_lpeg); + lua_setfield(L, -2, "lpeg"); + lua_pop(L, 1); // remove PRELOAD table +#endif + installLibraryLoader(L); + lua_pop(L, 1); lua_pushcfunction(L, [](lua_State *L) -> int { OgreAssert(false, "Crash function called"); return 0; @@ -40,9 +134,24 @@ LuaData::LuaData() }); lua_setglobal(L, "setup_handler"); lua_pushcfunction(L, [](lua_State *L) -> int { + int args = lua_gettop(L); + if (args < 1) + return 0; + ECS::get_mut().choices.clear(); luaL_checktype(L, 1, LUA_TSTRING); + if (args > 1) { + luaL_checktype(L, 2, LUA_TTABLE); + lua_pushnil(L); /* first key */ + while (lua_next(L, 2) != 0) { + Ogre::String s = lua_tostring(L, -1); + ECS::get_mut().choices.push_back(s); + lua_pop(L, 1); /* remove value but keep key */ + } + } size_t len; Ogre::String message(luaL_tolstring(L, 1, &len)); + std::cout << "narrator message: " << message + << " length: " << message.length() << std::endl; if (message.length() == 0 && ECS::get_mut().narrationBox) { ECS::get_mut().enabled = false; ECS::get_mut().grab = true; @@ -50,12 +159,11 @@ LuaData::LuaData() ECS::get_mut().narrationText = message; ECS::get_mut().narrationBox = false; ECS::modified(); - } else { + std::cout << "narration ended\n"; + } else if (message.length() > 0) { std::replace(message.begin(), message.end(), '\n', ' '); std::replace(message.begin(), message.end(), '\r', ' '); - std::cout << "narrator message: " << message - << std::endl; ECS::get_mut().enabled = true; ECS::get_mut().grab = false; ECS::get_mut().grabChanged = true; @@ -67,6 +175,12 @@ LuaData::LuaData() return 0; }); lua_setglobal(L, "narrate"); + lua_pushcfunction(L, [](lua_State *L) -> int { + // ECS::get_mut().mainMenu = true; + lua_pushinteger(L, ECS::get().narration_answer); + return 1; + }); + lua_setglobal(L, "narration_get_answer"); lua_pushcfunction(L, [](lua_State *L) -> int { // ECS::get_mut().mainMenu = true; ECS::get_mut().enabled = true; diff --git a/src/gamedata/TerrainModule.cpp b/src/gamedata/TerrainModule.cpp index dab6de2..b2b657e 100644 --- a/src/gamedata/TerrainModule.cpp +++ b/src/gamedata/TerrainModule.cpp @@ -589,18 +589,19 @@ TerrainModule::TerrainModule(flecs::world &ecs) .position << std::endl; } + flecs::entity player = ECS::player; + CharacterLocation &loc = + player.get_mut(); + height = get_height(terrain.mTerrainGroup, + loc.position); + loc.position.y = height + 0.0f; + player.get() + .mBodyNode->setPosition(loc.position); + player.get() + .mBodyNode->setOrientation( + Ogre::Quaternion()); + player.modified(); } - flecs::entity player = ECS::player; - CharacterLocation &loc = - player.get_mut(); - float height = - get_height(terrain.mTerrainGroup, loc.position); - loc.position.y = height + 0.0f; - player.get().mBodyNode->setPosition( - loc.position); - player.get().mBodyNode->setOrientation( - Ogre::Quaternion()); - player.modified(); }); } float TerrainModule::get_height(Ogre::TerrainGroup *group, diff --git a/src/lua/CMakeLists.txt b/src/lua/CMakeLists.txt index 2a97b51..20e3d8a 100644 --- a/src/lua/CMakeLists.txt +++ b/src/lua/CMakeLists.txt @@ -14,17 +14,28 @@ set(LUA_OBJ lmathlib.o loadlib.o loslib.o lstrlib.o ltablib.o lutf8lib.o linit.o) set(LUA_SRC) +set(LPEG_OBJ +lpvm.o lpcap.o lptree.o lpcode.o lpprint.o lpcset.o +) +set(LPEG_SRC) foreach(LUA_FILE ${LUA_OBJ}) string(REPLACE ".o" ".c" LUA_SRC_ITEM ${LUA_FILE}) list(APPEND LUA_SRC lua-5.4.8/src/${LUA_SRC_ITEM}) endforeach() -add_library(lua ${LUA_SRC}) -target_include_directories(lua PUBLIC lua-5.4.8/src) +foreach(LPEG_FILE ${LPEG_OBJ}) + string(REPLACE ".o" ".c" LPEG_SRC_ITEM ${LPEG_FILE}) + list(APPEND LPEG_SRC lpeg-1.1.0/${LPEG_SRC_ITEM}) +endforeach() +add_library(lua ${LUA_SRC} ${LPEG_SRC}) +target_include_directories(lua PUBLIC lua-5.4.8/src lpeg-1.1.0) add_executable(luavm lua-5.4.8/src/lua.c) target_link_libraries(luavm lua m) target_include_directories(luavm PRIVATE lua-5.4.8/src) -add_executable(luac lua-5.4.8/src/luac.c ${LUA_SRC}) +add_executable(luac lua-5.4.8/src/luac.c ${LUA_SRC} ${LPEG_SRC}) target_link_libraries(luac m) -target_include_directories(luac PRIVATE lua-5.4.8/src) +target_include_directories(luac PRIVATE lua-5.4.8/src lpeg-1.1.0) +add_executable(lualpegvm lua.c) +target_link_libraries(lualpegvm lua m) +target_include_directories(lualpegvm PRIVATE lua-5.4.8/src) diff --git a/src/lua/lua.c b/src/lua/lua.c new file mode 100644 index 0000000..bde43c8 --- /dev/null +++ b/src/lua/lua.c @@ -0,0 +1,694 @@ +/* +** $Id: lua.c $ +** Lua stand-alone interpreter +** See Copyright Notice in lua.h +*/ + +#define lua_c + +#include "lprefix.h" + + +#include +#include +#include + +#include + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + +#if !defined(LUA_PROGNAME) +#define LUA_PROGNAME "lua" +#endif + +#if !defined(LUA_INIT_VAR) +#define LUA_INIT_VAR "LUA_INIT" +#endif + +#define LUA_INITVARVERSION LUA_INIT_VAR LUA_VERSUFFIX + + +static lua_State *globalL = NULL; + +static const char *progname = LUA_PROGNAME; + + +#if defined(LUA_USE_POSIX) /* { */ + +/* +** Use 'sigaction' when available. +*/ +static void setsignal (int sig, void (*handler)(int)) { + struct sigaction sa; + sa.sa_handler = handler; + sa.sa_flags = 0; + sigemptyset(&sa.sa_mask); /* do not mask any signal */ + sigaction(sig, &sa, NULL); +} + +#else /* }{ */ + +#define setsignal signal + +#endif /* } */ + + +/* +** Hook set by signal function to stop the interpreter. +*/ +static void lstop (lua_State *L, lua_Debug *ar) { + (void)ar; /* unused arg. */ + lua_sethook(L, NULL, 0, 0); /* reset hook */ + luaL_error(L, "interrupted!"); +} + + +/* +** Function to be called at a C signal. Because a C signal cannot +** just change a Lua state (as there is no proper synchronization), +** this function only sets a hook that, when called, will stop the +** interpreter. +*/ +static void laction (int i) { + int flag = LUA_MASKCALL | LUA_MASKRET | LUA_MASKLINE | LUA_MASKCOUNT; + setsignal(i, SIG_DFL); /* if another SIGINT happens, terminate process */ + lua_sethook(globalL, lstop, flag, 1); +} + + +static void print_usage (const char *badoption) { + lua_writestringerror("%s: ", progname); + if (badoption[1] == 'e' || badoption[1] == 'l') + lua_writestringerror("'%s' needs argument\n", badoption); + else + lua_writestringerror("unrecognized option '%s'\n", badoption); + lua_writestringerror( + "usage: %s [options] [script [args]]\n" + "Available options are:\n" + " -e stat execute string 'stat'\n" + " -i enter interactive mode after executing 'script'\n" + " -l mod require library 'mod' into global 'mod'\n" + " -l g=mod require library 'mod' into global 'g'\n" + " -v show version information\n" + " -E ignore environment variables\n" + " -W turn warnings on\n" + " -- stop handling options\n" + " - stop handling options and execute stdin\n" + , + progname); +} + + +/* +** Prints an error message, adding the program name in front of it +** (if present) +*/ +static void l_message (const char *pname, const char *msg) { + if (pname) lua_writestringerror("%s: ", pname); + lua_writestringerror("%s\n", msg); +} + + +/* +** Check whether 'status' is not OK and, if so, prints the error +** message on the top of the stack. +*/ +static int report (lua_State *L, int status) { + if (status != LUA_OK) { + const char *msg = lua_tostring(L, -1); + if (msg == NULL) + msg = "(error message not a string)"; + l_message(progname, msg); + lua_pop(L, 1); /* remove message */ + } + return status; +} + + +/* +** Message handler used to run all chunks +*/ +static int msghandler (lua_State *L) { + const char *msg = lua_tostring(L, 1); + if (msg == NULL) { /* is error object not a string? */ + if (luaL_callmeta(L, 1, "__tostring") && /* does it have a metamethod */ + lua_type(L, -1) == LUA_TSTRING) /* that produces a string? */ + return 1; /* that is the message */ + else + msg = lua_pushfstring(L, "(error object is a %s value)", + luaL_typename(L, 1)); + } + luaL_traceback(L, L, msg, 1); /* append a standard traceback */ + return 1; /* return the traceback */ +} + + +/* +** Interface to 'lua_pcall', which sets appropriate message function +** and C-signal handler. Used to run all chunks. +*/ +static int docall (lua_State *L, int narg, int nres) { + int status; + int base = lua_gettop(L) - narg; /* function index */ + lua_pushcfunction(L, msghandler); /* push message handler */ + lua_insert(L, base); /* put it under function and args */ + globalL = L; /* to be available to 'laction' */ + setsignal(SIGINT, laction); /* set C-signal handler */ + status = lua_pcall(L, narg, nres, base); + setsignal(SIGINT, SIG_DFL); /* reset C-signal handler */ + lua_remove(L, base); /* remove message handler from the stack */ + return status; +} + + +static void print_version (void) { + lua_writestring(LUA_COPYRIGHT, strlen(LUA_COPYRIGHT)); + lua_writeline(); +} + + +/* +** Create the 'arg' table, which stores all arguments from the +** command line ('argv'). It should be aligned so that, at index 0, +** it has 'argv[script]', which is the script name. The arguments +** to the script (everything after 'script') go to positive indices; +** other arguments (before the script name) go to negative indices. +** If there is no script name, assume interpreter's name as base. +** (If there is no interpreter's name either, 'script' is -1, so +** table sizes are zero.) +*/ +static void createargtable (lua_State *L, char **argv, int argc, int script) { + int i, narg; + narg = argc - (script + 1); /* number of positive indices */ + lua_createtable(L, narg, script + 1); + for (i = 0; i < argc; i++) { + lua_pushstring(L, argv[i]); + lua_rawseti(L, -2, i - script); + } + lua_setglobal(L, "arg"); +} + + +static int dochunk (lua_State *L, int status) { + if (status == LUA_OK) status = docall(L, 0, 0); + return report(L, status); +} + + +static int dofile (lua_State *L, const char *name) { + return dochunk(L, luaL_loadfile(L, name)); +} + + +static int dostring (lua_State *L, const char *s, const char *name) { + return dochunk(L, luaL_loadbuffer(L, s, strlen(s), name)); +} + + +/* +** Receives 'globname[=modname]' and runs 'globname = require(modname)'. +** If there is no explicit modname and globname contains a '-', cut +** the suffix after '-' (the "version") to make the global name. +*/ +static int dolibrary (lua_State *L, char *globname) { + int status; + char *suffix = NULL; + char *modname = strchr(globname, '='); + if (modname == NULL) { /* no explicit name? */ + modname = globname; /* module name is equal to global name */ + suffix = strchr(modname, *LUA_IGMARK); /* look for a suffix mark */ + } + else { + *modname = '\0'; /* global name ends here */ + modname++; /* module name starts after the '=' */ + } + lua_getglobal(L, "require"); + lua_pushstring(L, modname); + status = docall(L, 1, 1); /* call 'require(modname)' */ + if (status == LUA_OK) { + if (suffix != NULL) /* is there a suffix mark? */ + *suffix = '\0'; /* remove suffix from global name */ + lua_setglobal(L, globname); /* globname = require(modname) */ + } + return report(L, status); +} + + +/* +** Push on the stack the contents of table 'arg' from 1 to #arg +*/ +static int pushargs (lua_State *L) { + int i, n; + if (lua_getglobal(L, "arg") != LUA_TTABLE) + luaL_error(L, "'arg' is not a table"); + n = (int)luaL_len(L, -1); + luaL_checkstack(L, n + 3, "too many arguments to script"); + for (i = 1; i <= n; i++) + lua_rawgeti(L, -i, i); + lua_remove(L, -i); /* remove table from the stack */ + return n; +} + + +static int handle_script (lua_State *L, char **argv) { + int status; + const char *fname = argv[0]; + if (strcmp(fname, "-") == 0 && strcmp(argv[-1], "--") != 0) + fname = NULL; /* stdin */ + status = luaL_loadfile(L, fname); + if (status == LUA_OK) { + int n = pushargs(L); /* push arguments to script */ + status = docall(L, n, LUA_MULTRET); + } + return report(L, status); +} + + +/* bits of various argument indicators in 'args' */ +#define has_error 1 /* bad option */ +#define has_i 2 /* -i */ +#define has_v 4 /* -v */ +#define has_e 8 /* -e */ +#define has_E 16 /* -E */ + + +/* +** Traverses all arguments from 'argv', returning a mask with those +** needed before running any Lua code or an error code if it finds any +** invalid argument. In case of error, 'first' is the index of the bad +** argument. Otherwise, 'first' is -1 if there is no program name, +** 0 if there is no script name, or the index of the script name. +*/ +static int collectargs (char **argv, int *first) { + int args = 0; + int i; + if (argv[0] != NULL) { /* is there a program name? */ + if (argv[0][0]) /* not empty? */ + progname = argv[0]; /* save it */ + } + else { /* no program name */ + *first = -1; + return 0; + } + for (i = 1; argv[i] != NULL; i++) { /* handle arguments */ + *first = i; + if (argv[i][0] != '-') /* not an option? */ + return args; /* stop handling options */ + switch (argv[i][1]) { /* else check option */ + case '-': /* '--' */ + if (argv[i][2] != '\0') /* extra characters after '--'? */ + return has_error; /* invalid option */ + *first = i + 1; + return args; + case '\0': /* '-' */ + return args; /* script "name" is '-' */ + case 'E': + if (argv[i][2] != '\0') /* extra characters? */ + return has_error; /* invalid option */ + args |= has_E; + break; + case 'W': + if (argv[i][2] != '\0') /* extra characters? */ + return has_error; /* invalid option */ + break; + case 'i': + args |= has_i; /* (-i implies -v) *//* FALLTHROUGH */ + case 'v': + if (argv[i][2] != '\0') /* extra characters? */ + return has_error; /* invalid option */ + args |= has_v; + break; + case 'e': + args |= has_e; /* FALLTHROUGH */ + case 'l': /* both options need an argument */ + if (argv[i][2] == '\0') { /* no concatenated argument? */ + i++; /* try next 'argv' */ + if (argv[i] == NULL || argv[i][0] == '-') + return has_error; /* no next argument or it is another option */ + } + break; + default: /* invalid option */ + return has_error; + } + } + *first = 0; /* no script name */ + return args; +} + + +/* +** Processes options 'e' and 'l', which involve running Lua code, and +** 'W', which also affects the state. +** Returns 0 if some code raises an error. +*/ +static int runargs (lua_State *L, char **argv, int n) { + int i; + for (i = 1; i < n; i++) { + int option = argv[i][1]; + lua_assert(argv[i][0] == '-'); /* already checked */ + switch (option) { + case 'e': case 'l': { + int status; + char *extra = argv[i] + 2; /* both options need an argument */ + if (*extra == '\0') extra = argv[++i]; + lua_assert(extra != NULL); + status = (option == 'e') + ? dostring(L, extra, "=(command line)") + : dolibrary(L, extra); + if (status != LUA_OK) return 0; + break; + } + case 'W': + lua_warning(L, "@on", 0); /* warnings on */ + break; + } + } + return 1; +} + + +static int handle_luainit (lua_State *L) { + const char *name = "=" LUA_INITVARVERSION; + const char *init = getenv(name + 1); + if (init == NULL) { + name = "=" LUA_INIT_VAR; + init = getenv(name + 1); /* try alternative name */ + } + if (init == NULL) return LUA_OK; + else if (init[0] == '@') + return dofile(L, init+1); + else + return dostring(L, init, name); +} + + +/* +** {================================================================== +** Read-Eval-Print Loop (REPL) +** =================================================================== +*/ + +#if !defined(LUA_PROMPT) +#define LUA_PROMPT "> " +#define LUA_PROMPT2 ">> " +#endif + +#if !defined(LUA_MAXINPUT) +#define LUA_MAXINPUT 512 +#endif + + +/* +** lua_stdin_is_tty detects whether the standard input is a 'tty' (that +** is, whether we're running lua interactively). +*/ +#if !defined(lua_stdin_is_tty) /* { */ + +#if defined(LUA_USE_POSIX) /* { */ + +#include +#define lua_stdin_is_tty() isatty(0) + +#elif defined(LUA_USE_WINDOWS) /* }{ */ + +#include +#include + +#define lua_stdin_is_tty() _isatty(_fileno(stdin)) + +#else /* }{ */ + +/* ISO C definition */ +#define lua_stdin_is_tty() 1 /* assume stdin is a tty */ + +#endif /* } */ + +#endif /* } */ + + +/* +** lua_readline defines how to show a prompt and then read a line from +** the standard input. +** lua_saveline defines how to "save" a read line in a "history". +** lua_freeline defines how to free a line read by lua_readline. +*/ +#if !defined(lua_readline) /* { */ + +#if defined(LUA_USE_READLINE) /* { */ + +#include +#include +#define lua_initreadline(L) ((void)L, rl_readline_name="lua") +#define lua_readline(L,b,p) ((void)L, ((b)=readline(p)) != NULL) +#define lua_saveline(L,line) ((void)L, add_history(line)) +#define lua_freeline(L,b) ((void)L, free(b)) + +#else /* }{ */ + +#define lua_initreadline(L) ((void)L) +#define lua_readline(L,b,p) \ + ((void)L, fputs(p, stdout), fflush(stdout), /* show prompt */ \ + fgets(b, LUA_MAXINPUT, stdin) != NULL) /* get line */ +#define lua_saveline(L,line) { (void)L; (void)line; } +#define lua_freeline(L,b) { (void)L; (void)b; } + +#endif /* } */ + +#endif /* } */ + + +/* +** Return the string to be used as a prompt by the interpreter. Leave +** the string (or nil, if using the default value) on the stack, to keep +** it anchored. +*/ +static const char *get_prompt (lua_State *L, int firstline) { + if (lua_getglobal(L, firstline ? "_PROMPT" : "_PROMPT2") == LUA_TNIL) + return (firstline ? LUA_PROMPT : LUA_PROMPT2); /* use the default */ + else { /* apply 'tostring' over the value */ + const char *p = luaL_tolstring(L, -1, NULL); + lua_remove(L, -2); /* remove original value */ + return p; + } +} + +/* mark in error messages for incomplete statements */ +#define EOFMARK "" +#define marklen (sizeof(EOFMARK)/sizeof(char) - 1) + + +/* +** Check whether 'status' signals a syntax error and the error +** message at the top of the stack ends with the above mark for +** incomplete statements. +*/ +static int incomplete (lua_State *L, int status) { + if (status == LUA_ERRSYNTAX) { + size_t lmsg; + const char *msg = lua_tolstring(L, -1, &lmsg); + if (lmsg >= marklen && strcmp(msg + lmsg - marklen, EOFMARK) == 0) + return 1; + } + return 0; /* else... */ +} + + +/* +** Prompt the user, read a line, and push it into the Lua stack. +*/ +static int pushline (lua_State *L, int firstline) { + char buffer[LUA_MAXINPUT]; + char *b = buffer; + size_t l; + const char *prmt = get_prompt(L, firstline); + int readstatus = lua_readline(L, b, prmt); + lua_pop(L, 1); /* remove prompt */ + if (readstatus == 0) + return 0; /* no input */ + l = strlen(b); + if (l > 0 && b[l-1] == '\n') /* line ends with newline? */ + b[--l] = '\0'; /* remove it */ + if (firstline && b[0] == '=') /* for compatibility with 5.2, ... */ + lua_pushfstring(L, "return %s", b + 1); /* change '=' to 'return' */ + else + lua_pushlstring(L, b, l); + lua_freeline(L, b); + return 1; +} + + +/* +** Try to compile line on the stack as 'return ;'; on return, stack +** has either compiled chunk or original line (if compilation failed). +*/ +static int addreturn (lua_State *L) { + const char *line = lua_tostring(L, -1); /* original line */ + const char *retline = lua_pushfstring(L, "return %s;", line); + int status = luaL_loadbuffer(L, retline, strlen(retline), "=stdin"); + if (status == LUA_OK) { + lua_remove(L, -2); /* remove modified line */ + if (line[0] != '\0') /* non empty? */ + lua_saveline(L, line); /* keep history */ + } + else + lua_pop(L, 2); /* pop result from 'luaL_loadbuffer' and modified line */ + return status; +} + + +/* +** Read multiple lines until a complete Lua statement +*/ +static int multiline (lua_State *L) { + for (;;) { /* repeat until gets a complete statement */ + size_t len; + const char *line = lua_tolstring(L, 1, &len); /* get what it has */ + int status = luaL_loadbuffer(L, line, len, "=stdin"); /* try it */ + if (!incomplete(L, status) || !pushline(L, 0)) { + lua_saveline(L, line); /* keep history */ + return status; /* should not or cannot try to add continuation line */ + } + lua_remove(L, -2); /* remove error message (from incomplete line) */ + lua_pushliteral(L, "\n"); /* add newline... */ + lua_insert(L, -2); /* ...between the two lines */ + lua_concat(L, 3); /* join them */ + } +} + + +/* +** Read a line and try to load (compile) it first as an expression (by +** adding "return " in front of it) and second as a statement. Return +** the final status of load/call with the resulting function (if any) +** in the top of the stack. +*/ +static int loadline (lua_State *L) { + int status; + lua_settop(L, 0); + if (!pushline(L, 1)) + return -1; /* no input */ + if ((status = addreturn(L)) != LUA_OK) /* 'return ...' did not work? */ + status = multiline(L); /* try as command, maybe with continuation lines */ + lua_remove(L, 1); /* remove line from the stack */ + lua_assert(lua_gettop(L) == 1); + return status; +} + + +/* +** Prints (calling the Lua 'print' function) any values on the stack +*/ +static void l_print (lua_State *L) { + int n = lua_gettop(L); + if (n > 0) { /* any result to be printed? */ + luaL_checkstack(L, LUA_MINSTACK, "too many results to print"); + lua_getglobal(L, "print"); + lua_insert(L, 1); + if (lua_pcall(L, n, 0, 0) != LUA_OK) + l_message(progname, lua_pushfstring(L, "error calling 'print' (%s)", + lua_tostring(L, -1))); + } +} + + +/* +** Do the REPL: repeatedly read (load) a line, evaluate (call) it, and +** print any results. +*/ +static void doREPL (lua_State *L) { + int status; + const char *oldprogname = progname; + progname = NULL; /* no 'progname' on errors in interactive mode */ + lua_initreadline(L); + while ((status = loadline(L)) != -1) { + if (status == LUA_OK) + status = docall(L, 0, LUA_MULTRET); + if (status == LUA_OK) l_print(L); + else report(L, status); + } + lua_settop(L, 0); /* clear stack */ + lua_writeline(); + progname = oldprogname; +} + +/* }================================================================== */ + + +/* +** Main body of stand-alone interpreter (to be called in protected mode). +** Reads the options and handles them all. +*/ +extern int luaopen_lpeg(lua_State *L); +static int pmain (lua_State *L) { + int argc = (int)lua_tointeger(L, 1); + char **argv = (char **)lua_touserdata(L, 2); + int script; + int args = collectargs(argv, &script); + int optlim = (script > 0) ? script : argc; /* first argv not an option */ + luaL_checkversion(L); /* check that interpreter has correct version */ + if (args == has_error) { /* bad arg? */ + print_usage(argv[script]); /* 'script' has index of bad arg. */ + return 0; + } + if (args & has_v) /* option '-v'? */ + print_version(); + if (args & has_E) { /* option '-E'? */ + lua_pushboolean(L, 1); /* signal for libraries to ignore env. vars. */ + lua_setfield(L, LUA_REGISTRYINDEX, "LUA_NOENV"); + } + luaL_openlibs(L); /* open standard libraries */ + luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_PRELOAD_TABLE); + lua_pushcfunction(L, luaopen_lpeg); + lua_setfield(L, -2, "lpeg"); + lua_pop(L, 1); // remove PRELOAD table + + createargtable(L, argv, argc, script); /* create table 'arg' */ + lua_gc(L, LUA_GCRESTART); /* start GC... */ + lua_gc(L, LUA_GCGEN, 0, 0); /* ...in generational mode */ + if (!(args & has_E)) { /* no option '-E'? */ + if (handle_luainit(L) != LUA_OK) /* run LUA_INIT */ + return 0; /* error running LUA_INIT */ + } + if (!runargs(L, argv, optlim)) /* execute arguments -e and -l */ + return 0; /* something failed */ + if (script > 0) { /* execute main script (if there is one) */ + if (handle_script(L, argv + script) != LUA_OK) + return 0; /* interrupt in case of error */ + } + if (args & has_i) /* -i option? */ + doREPL(L); /* do read-eval-print loop */ + else if (script < 1 && !(args & (has_e | has_v))) { /* no active option? */ + if (lua_stdin_is_tty()) { /* running in interactive mode? */ + print_version(); + doREPL(L); /* do read-eval-print loop */ + } + else dofile(L, NULL); /* executes stdin as a file */ + } + lua_pushboolean(L, 1); /* signal no errors */ + return 1; +} + + + +int main (int argc, char **argv) { + int status, result; + lua_State *L = luaL_newstate(); /* create state */ + if (L == NULL) { + l_message(argv[0], "cannot create state: not enough memory"); + return EXIT_FAILURE; + } + lua_gc(L, LUA_GCSTOP); /* stop GC while building state */ + lua_pushcfunction(L, &pmain); /* to call 'pmain' in protected mode */ + lua_pushinteger(L, argc); /* 1st argument */ + lua_pushlightuserdata(L, argv); /* 2nd argument */ + status = lua_pcall(L, 2, 1, 0); /* do the call */ + result = lua_toboolean(L, -1); /* get result */ + report(L, status); + lua_close(L); + return (result && status == LUA_OK) ? EXIT_SUCCESS : EXIT_FAILURE; +} + diff --git a/world_map.kra b/world_map.kra index 5d5c051..7c540f4 100644 --- a/world_map.kra +++ b/world_map.kra @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:750dc44454b807f45f5e12ee6eadd34db16657d7897560a02ac92dceea296cf0 -size 683352 +oid sha256:2967024ba5c85807445ce1e2aa2d9ca76683cabcd63ddd982105c8dc55439dec +size 683366