├── stories ├── debug.ink ├── game.ink └── game.lua ├── test ├── units │ ├── text-line.txt │ ├── text-line.ink │ ├── escape.ink │ ├── escape.txt │ ├── functions.txt │ ├── inclusions.ink │ ├── inclusions.txt │ ├── text-glue.txt │ ├── text-lines.ink │ ├── text-lines.txt │ ├── expressions.txt │ ├── knots.txt │ ├── vars.txt │ ├── stitches │ │ ├── 2.txt │ │ └── 1-1.txt │ ├── choices-sticky │ │ ├── 2.txt │ │ ├── 1-1-2.txt │ │ ├── 1-2-2.txt │ │ ├── 1-1-1-1-1.txt │ │ ├── 1-2-1-1-1.txt │ │ ├── 1-1-1-1-2.txt │ │ └── 1-2-1-1-2.txt │ ├── choices-tunnel.txt │ ├── text-tags.txt │ ├── choices-tunnel.ink │ ├── choices-conditional │ │ ├── 1-2.txt │ │ ├── 2-2.txt │ │ ├── 1-1-1.txt │ │ └── 2-1-1.txt │ ├── choices-tags │ │ ├── 2.txt │ │ └── 1.txt │ ├── text-tags.ink │ ├── choices-tags.ink │ ├── constants.txt │ ├── tunnels.txt │ ├── choices-basic │ │ ├── 2.txt │ │ ├── 1.txt │ │ ├── 4.txt │ │ └── 3.txt │ ├── nesting │ │ ├── 2.txt │ │ ├── 1-2.txt │ │ ├── 1-1-2-1.txt │ │ ├── 1-1-1-1.txt │ │ ├── 1-1-1-2.txt │ │ ├── 1-1-2-2.txt │ │ ├── 1-1-2-3.txt │ │ └── 1-1-1-3.txt │ ├── queries.txt │ ├── text-glue.ink │ ├── labels-nested │ │ ├── 1-2.txt │ │ ├── 2-2.txt │ │ ├── 1-1-1.txt │ │ └── 2-1-1.txt │ ├── gather.ink │ ├── branching │ │ ├── 3.txt │ │ ├── 1.txt │ │ └── 2.txt │ ├── comments.txt │ ├── gather.txt │ ├── expressions.ink │ ├── choices-basic.ink │ ├── conditions-inline.txt │ ├── knots.ink │ ├── labels-choices │ │ ├── 1-1.txt │ │ ├── 1-2.txt │ │ ├── 2-1.txt │ │ ├── 2-2-1.txt │ │ └── 2-2-2.txt │ ├── conditions-switch.txt │ ├── choices-sticky.ink │ ├── vars.ink │ ├── loop │ │ ├── 2-3.txt │ │ ├── 3-3.txt │ │ ├── 1-3.txt │ │ ├── 2-2-2.txt │ │ ├── 3-2-2.txt │ │ ├── 1-1-2.txt │ │ ├── 2-1-2.txt │ │ ├── 1-2-2.txt │ │ ├── 3-1-2.txt │ │ ├── 1-1-1.txt │ │ ├── 2-1-1.txt │ │ ├── 1-2-1.txt │ │ ├── 2-2-1.txt │ │ ├── 3-1-1.txt │ │ └── 3-2-1.txt │ ├── functions.ink │ ├── choices-conditional.ink │ ├── choices-fallback.ink │ ├── stitches.ink │ ├── comments.ink │ ├── queries.ink │ ├── tunnels.ink │ ├── labels-nested.ink │ ├── conditions-inline.ink │ ├── choices-fallback │ │ ├── 2-1.txt │ │ └── 1-1.txt │ ├── lists-basic.txt │ ├── lists-queries.txt │ ├── branching.ink │ ├── loop.ink │ ├── constants.ink │ ├── alts-blocks.txt │ ├── nesting.ink │ ├── lists-operators.txt │ ├── labels-choices.ink │ ├── alts-inline.ink │ ├── alts-inline.txt │ ├── alts-blocks.ink │ ├── conditions-switch.ink │ ├── lists-basic.ink │ ├── lists-queries.ink │ └── lists-operators.ink ├── runtime │ ├── observing.lua │ ├── continue.lua │ ├── set-get.lua │ ├── binding.lua │ ├── jumping.lua │ ├── tags.lua │ ├── save-load.lua │ └── visits.lua ├── cases.lua └── run.lua ├── .gitignore ├── example-defold ├── example.font ├── examlpe.collection ├── book.ink ├── example.gui └── example.gui_script ├── .vscode ├── tasks.json ├── launch.json └── settings.json ├── game.project ├── narrator ├── enums.lua ├── annotations.lua ├── libs │ ├── classic.lua │ └── lume.lua ├── narrator.lua ├── list │ └── mt.lua └── parser.lua ├── debug.lua ├── LICENSE ├── bot.lua ├── game.lua └── README.md /stories/debug.ink: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/units/text-line.txt: -------------------------------------------------------------------------------- 1 | Hello, world! I am a string. -------------------------------------------------------------------------------- /test/units/text-line.ink: -------------------------------------------------------------------------------- 1 | Hello, world! I am a string. -------------------------------------------------------------------------------- /test/units/escape.ink: -------------------------------------------------------------------------------- 1 | The \| dark \{grass\} is soft under your feet. -------------------------------------------------------------------------------- /test/units/escape.txt: -------------------------------------------------------------------------------- 1 | The | dark {grass} is soft under your feet. -------------------------------------------------------------------------------- /test/units/functions.txt: -------------------------------------------------------------------------------- 1 | first param is 2 2 | second param is 3 3 | sum: 5 -------------------------------------------------------------------------------- /test/units/inclusions.ink: -------------------------------------------------------------------------------- 1 | INCLUDE text-line.ink 2 | 3 | A line after a line -------------------------------------------------------------------------------- /test/units/inclusions.txt: -------------------------------------------------------------------------------- 1 | Hello, world! I am a string. 2 | A line after a line -------------------------------------------------------------------------------- /test/units/text-glue.txt: -------------------------------------------------------------------------------- 1 | We hurried home to Savile Row as fast as we could. -------------------------------------------------------------------------------- /test/units/text-lines.ink: -------------------------------------------------------------------------------- 1 | Hello, world! 2 | Hello? 3 | Hello, are you there? -------------------------------------------------------------------------------- /test/units/text-lines.txt: -------------------------------------------------------------------------------- 1 | Hello, world! 2 | Hello? 3 | Hello, are you there? -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # System 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # Defold 6 | /.internal 7 | /build -------------------------------------------------------------------------------- /test/units/expressions.txt: -------------------------------------------------------------------------------- 1 | My friends call me Katy. I'm 32 years old. 2 | Wow, 2 x 2 = 4! 3 | I have 1 dalmatian. -------------------------------------------------------------------------------- /test/units/knots.txt: -------------------------------------------------------------------------------- 1 | We arrived into London at 9.45pm exactly. 2 | We hurried home to Savile Row as fast as we could. -------------------------------------------------------------------------------- /test/units/vars.txt: -------------------------------------------------------------------------------- 1 | 100 2 | 101 3 | 201 4 | 151 5 | 150 6 | Hello, Katy! Do you have 500 bucks? 20 pennies may be? 7 | Nope. -------------------------------------------------------------------------------- /test/units/stitches/2.txt: -------------------------------------------------------------------------------- 1 | We boarded the train, but where? 2 | 3 | 1) First class 4 | >) Travel in the guard's van 5 | 6 | Whoosh.. -------------------------------------------------------------------------------- /test/units/choices-sticky/2.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) Eat another donut 3 | >) Get off the couch 4 | 5 | You struggle up off the couch to go and compose epic poetry. -------------------------------------------------------------------------------- /test/units/choices-tunnel.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Master #test 3 | 4 | "What's that?" my master asked again. 5 | 6 | >) I don't know. 7 | 8 | I don't know. 9 | Okay... -------------------------------------------------------------------------------- /test/units/text-tags.txt: -------------------------------------------------------------------------------- 1 | A line of normal game-text. #globalTag1 #globalTag2 #globalTag3 #colour it blue 2 | A line of normal game-text. #tag1 #tag two #tag 3 -------------------------------------------------------------------------------- /test/units/choices-tunnel.ink: -------------------------------------------------------------------------------- 1 | * [Master] ->master-> # test 2 | Okay... 3 | 4 | === master 5 | "What's that?" my master asked again. 6 | * I don't know. 7 | ->-> -------------------------------------------------------------------------------- /test/units/choices-conditional/1-2.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Go to Paris 3 | 2) Go to London 4 | 5 | Hello Paris! 6 | 7 | 1) Go to London 8 | >) Go home 9 | 10 | Sweet home... -------------------------------------------------------------------------------- /test/units/choices-conditional/2-2.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) Go to Paris 3 | >) Go to London 4 | 5 | Hello London! 6 | 7 | 1) Go to Paris 8 | >) Go home 9 | 10 | Sweet home... -------------------------------------------------------------------------------- /test/units/choices-tags/2.txt: -------------------------------------------------------------------------------- 1 | Let's try an experemental feature: 2 | The Choice Tags! 3 | 4 | 1) Are you seriously? #tag1 5 | >) Eat answer! #tag2 6 | 7 | Are you hungry? -------------------------------------------------------------------------------- /test/units/text-tags.ink: -------------------------------------------------------------------------------- 1 | # globalTag1 2 | # globalTag2 #globalTag3 3 | A line of normal game-text. # colour it blue 4 | A line of normal game-text. #tag1 #tag two # tag 3 -------------------------------------------------------------------------------- /test/units/choices-tags.ink: -------------------------------------------------------------------------------- 1 | Let's try an experemental feature: 2 | The Choice Tags! 3 | * Are you seriously? #tag1 4 | Yeap, absolutely! 5 | * [Eat answer!] #tag2 6 | Are you hungry? -------------------------------------------------------------------------------- /test/units/constants.txt: -------------------------------------------------------------------------------- 1 | This is the string constant. One is 1. Zero is 0. 2 | The secret agent moves forward. 3 | The secret agent moves forward. 4 | The secret agent grabs the suitcase! -------------------------------------------------------------------------------- /test/units/tunnels.txt: -------------------------------------------------------------------------------- 1 | The dark grass is soft under your feet. 2 | You lie down and try to close your eyes. 3 | You dream about the dream. 4 | You wake as the sun rises. 5 | It is time to move on. -------------------------------------------------------------------------------- /test/units/choices-basic/2.txt: -------------------------------------------------------------------------------- 1 | "What's that?" my master asked again. 2 | 3 | 1) I don't know. 4 | >) Eat answer! 5 | 3) "I am somewhat tired." 6 | 4) "Nothing, Monsieur!" 7 | 8 | Nice to hear from you! -------------------------------------------------------------------------------- /test/units/choices-tags/1.txt: -------------------------------------------------------------------------------- 1 | Let's try an experemental feature: 2 | The Choice Tags! 3 | 4 | >) Are you seriously? #tag1 5 | 2) Eat answer! #tag2 6 | 7 | Are you seriously? #tag1 8 | Yeap, absolutely! -------------------------------------------------------------------------------- /test/units/nesting/2.txt: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | 3 | 1) ... and I could contain myself no longer. 4 | >) ... but I said nothing 5 | 6 | ... but I said nothing and we passed the day in silence. -------------------------------------------------------------------------------- /test/units/choices-basic/1.txt: -------------------------------------------------------------------------------- 1 | "What's that?" my master asked again. 2 | 3 | >) I don't know. 4 | 2) Eat answer! 5 | 3) "I am somewhat tired." 6 | 4) "Nothing, Monsieur!" 7 | 8 | I don't know. 9 | Okay... -------------------------------------------------------------------------------- /test/units/queries.txt: -------------------------------------------------------------------------------- 1 | SEED = 81 2 | RANDOM(1, 100) = 81 3 | POW(2,4) = 16 4 | INT(1.45) = 1 5 | FLOOR(2.4) = 2 6 | FLOAT(3) = 3 7 | 8 | >) Choice 1 9 | 10 | True! 11 | False! 12 | True! 13 | False! -------------------------------------------------------------------------------- /test/units/text-glue.ink: -------------------------------------------------------------------------------- 1 | We hurried home <> 2 | -> to_savile_row 3 | 4 | === to_savile_row === 5 | to Savile Row 6 | -> as_fast_as_we_could 7 | 8 | === as_fast_as_we_could === 9 | <> as fast as we could. -------------------------------------------------------------------------------- /test/units/labels-nested/1-2.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Tell me about the mister 3 | 2) Tell me about the missis 4 | 5 | His name is Dima. 6 | 7 | 1) Tell me about Vika 8 | >) Finish conversation 9 | 10 | That's all. -------------------------------------------------------------------------------- /test/units/labels-nested/2-2.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) Tell me about the mister 3 | >) Tell me about the missis 4 | 5 | Her name is Vika. 6 | 7 | 1) Tell me about Dima 8 | >) Finish conversation 9 | 10 | That's all. -------------------------------------------------------------------------------- /test/units/choices-conditional/1-1-1.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Go to Paris 3 | 2) Go to London 4 | 5 | Hello Paris! 6 | 7 | >) Go to London 8 | 2) Go home 9 | 10 | Hello London! 11 | 12 | >) Go home 13 | 14 | Sweet home... -------------------------------------------------------------------------------- /test/units/choices-conditional/2-1-1.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) Go to Paris 3 | >) Go to London 4 | 5 | Hello London! 6 | 7 | >) Go to Paris 8 | 2) Go home 9 | 10 | Hello Paris! 11 | 12 | >) Go home 13 | 14 | Sweet home... -------------------------------------------------------------------------------- /test/units/stitches/1-1.txt: -------------------------------------------------------------------------------- 1 | We boarded the train, but where? 2 | 3 | >) First class 4 | 2) Travel in the guard's van 5 | 6 | I settled my master. 7 | 8 | >) Move to third class 9 | 10 | I put myself in third. -------------------------------------------------------------------------------- /test/units/gather.ink: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | * ... and I could contain myself no longer. 3 | 'What is the purpose of our journey, Monsieur?' 4 | 'A wager,' he replied. 5 | - 6 | we passed the day in silence. 7 | -> END -------------------------------------------------------------------------------- /test/units/choices-basic/4.txt: -------------------------------------------------------------------------------- 1 | "What's that?" my master asked again. 2 | 3 | 1) I don't know. 4 | 2) Eat answer! 5 | 3) "I am somewhat tired." 6 | >) "Nothing, Monsieur!" 7 | 8 | "Nothing, Monsieur!" I replied. 9 | "Very good, then." -------------------------------------------------------------------------------- /test/units/branching/3.txt: -------------------------------------------------------------------------------- 1 | We arrived into London at 9.45pm exactly. 2 | 3 | 1) 'There is not a moment to lose!' 4 | 2) 'Monsieur, let us savour this moment!' 5 | >) We hurried home 6 | 7 | We hurried home to Savile Row as fast as we could. -------------------------------------------------------------------------------- /test/units/choices-basic/3.txt: -------------------------------------------------------------------------------- 1 | "What's that?" my master asked again. 2 | 3 | 1) I don't know. 4 | 2) Eat answer! 5 | >) "I am somewhat tired." 6 | 4) "Nothing, Monsieur!" 7 | 8 | "I am somewhat tired," I repeated. 9 | "Really," he responded. "How deleterious." -------------------------------------------------------------------------------- /test/units/comments.txt: -------------------------------------------------------------------------------- 1 | "What do you make of this?" she asked. 2 | "I couldn't possibly comment," I replied. 3 | Before comment ... 4 | ... and after. Must be in one line, but right now it's known limitation. 5 | Text before TODO. TODO: This TODO will be printed. -------------------------------------------------------------------------------- /test/units/gather.txt: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | 3 | >) ... and I could contain myself no longer. 4 | 5 | ... and I could contain myself no longer. 6 | 'What is the purpose of our journey, Monsieur?' 7 | 'A wager,' he replied. 8 | we passed the day in silence. -------------------------------------------------------------------------------- /test/units/expressions.ink: -------------------------------------------------------------------------------- 1 | VAR friendly_name_of_player = "Katy" 2 | VAR age = "32" 3 | VAR dalmatians_count = 101 4 | 5 | My friends call me {friendly_name_of_player}. I'm { age } years old. 6 | Wow, 2 x 2 = { 2 * 2 }! 7 | I have { dalmatians_count / 101 } dalmatian. -------------------------------------------------------------------------------- /test/units/labels-nested/1-1-1.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Tell me about the mister 3 | 2) Tell me about the missis 4 | 5 | His name is Dima. 6 | 7 | >) Tell me about Vika 8 | 2) Finish conversation 9 | 10 | Her name is Vika. 11 | 12 | >) Finish conversation 13 | 14 | That's all. -------------------------------------------------------------------------------- /test/units/labels-nested/2-1-1.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) Tell me about the mister 3 | >) Tell me about the missis 4 | 5 | Her name is Vika. 6 | 7 | >) Tell me about Dima 8 | 2) Finish conversation 9 | 10 | His name is Dima. 11 | 12 | >) Finish conversation 13 | 14 | That's all. -------------------------------------------------------------------------------- /test/units/branching/1.txt: -------------------------------------------------------------------------------- 1 | We arrived into London at 9.45pm exactly. 2 | 3 | >) 'There is not a moment to lose!' 4 | 2) 'Monsieur, let us savour this moment!' 5 | 3) We hurried home 6 | 7 | 'There is not a moment to lose!' I declared. 8 | We hurried home to Savile Row as fast as we could. -------------------------------------------------------------------------------- /test/units/choices-basic.ink: -------------------------------------------------------------------------------- 1 | "What's that?" my master asked again. 2 | * I don't know. 3 | Okay... 4 | * [Eat answer!] 5 | Nice to hear from you! 6 | * "I am somewhat tired[."]," I repeated. 7 | "Really," he responded. "How deleterious." 8 | * "Nothing, Monsieur!"[] I replied. 9 | "Very good, then." -------------------------------------------------------------------------------- /test/units/conditions-inline.txt: -------------------------------------------------------------------------------- 1 | Simple condition: Okay. 2 | Simple condition: Hmm... I was feeling positive enough. Okay. 3 | Simple condition: Hmm... Wow! I was feeling positive enough. Okay. 4 | Complex condition with midnight. Wow! Nice! 5 | Complex condition with midnight. Very nice! This is the end. -------------------------------------------------------------------------------- /test/units/knots.ink: -------------------------------------------------------------------------------- 1 | -> back_in_london 2 | 3 | == back_in_london === 4 | 5 | We arrived into London at 9.45pm exactly. 6 | -> hurry_home 7 | 8 | === hurry_home = 9 | We hurried home to Savile Row -> as_fast_as_we_could 10 | 11 | ====== as_fast_as_we_could 12 | as fast as we could. -> END 13 | 14 | -------------------------------------------------------------------------------- /test/units/labels-choices/1-1.txt: -------------------------------------------------------------------------------- 1 | The guard frowns at you. 2 | 3 | >) Greet him 4 | 2) 'Get out of my way.' 5 | 6 | 'Greetings.' 7 | 'Hmm,' replies the guard. 8 | 9 | >) 'Having a nice day?' 10 | 2) 'Hmm?' 11 | 12 | 'Having a nice day?' 13 | 'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?' -------------------------------------------------------------------------------- /test/units/labels-choices/1-2.txt: -------------------------------------------------------------------------------- 1 | The guard frowns at you. 2 | 3 | >) Greet him 4 | 2) 'Get out of my way.' 5 | 6 | 'Greetings.' 7 | 'Hmm,' replies the guard. 8 | 9 | 1) 'Having a nice day?' 10 | >) 'Hmm?' 11 | 12 | 'Hmm?' you reply. 13 | 'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?' -------------------------------------------------------------------------------- /test/units/choices-sticky/1-1-2.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Eat another donut 3 | 2) Get off the couch 4 | 5 | You eat another donut. 6 | 7 | >) Ok, is it fine? 8 | 2) Ok, it's fine! 9 | 10 | Ok, is it fine? 11 | 12 | 1) Eat another donut 13 | >) Get off the couch 14 | 15 | You struggle up off the couch to go and compose epic poetry. -------------------------------------------------------------------------------- /test/units/choices-sticky/1-2-2.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Eat another donut 3 | 2) Get off the couch 4 | 5 | You eat another donut. 6 | 7 | 1) Ok, is it fine? 8 | >) Ok, it's fine! 9 | 10 | Ok, it's fine! 11 | 12 | 1) Eat another donut 13 | >) Get off the couch 14 | 15 | You struggle up off the couch to go and compose epic poetry. -------------------------------------------------------------------------------- /test/units/labels-choices/2-1.txt: -------------------------------------------------------------------------------- 1 | The guard frowns at you. 2 | 3 | 1) Greet him 4 | >) 'Get out of my way.' 5 | 6 | 'Get out of my way,' you tell the guard. 7 | 'Hmm,' replies the guard. 8 | 9 | >) 'Hmm?' 10 | 2) Shove him aside 11 | 12 | 'Hmm?' you reply. 13 | 'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?' -------------------------------------------------------------------------------- /test/units/conditions-switch.txt: -------------------------------------------------------------------------------- 1 | Hello! 2 | False! 3 | True! 4 | 5 | >) Answer 6 | 7 | True! 8 | Text here. 9 | Badaboom! 10 | ...again. I love you. 11 | And what about you? 12 | 13 | >) Choice 14 | 15 | Choice text x = 0 #success 16 | suc... 17 | success #success 18 | els... 19 | (notLabel) else 20 | zero 21 | one 22 | two 23 | lots -------------------------------------------------------------------------------- /test/units/choices-sticky.ink: -------------------------------------------------------------------------------- 1 | -> homers_couch 2 | 3 | === homers_couch === 4 | + [Eat another donut] 5 | You eat another donut. -> fine 6 | * [Get off the couch] 7 | You struggle up off the couch to go and compose epic poetry. 8 | -> END 9 | 10 | === fine === 11 | * Ok, is it fine? -> homers_couch 12 | * Ok, it's fine! -> homers_couch 13 | * -> END -------------------------------------------------------------------------------- /test/units/branching/2.txt: -------------------------------------------------------------------------------- 1 | We arrived into London at 9.45pm exactly. 2 | 3 | 1) 'There is not a moment to lose!' 4 | >) 'Monsieur, let us savour this moment!' 5 | 3) We hurried home 6 | 7 | 'Monsieur, let us savour this moment!' I declared. 8 | My master clouted me firmly around the head and dragged me out of the door. 9 | He insisted that we hurried home to Savile Row as fast as we could. -------------------------------------------------------------------------------- /test/units/vars.ink: -------------------------------------------------------------------------------- 1 | VAR money = 100 2 | VAR has_knife = true 3 | VAR name = "Katy" 4 | 5 | { money } 6 | ~ money++ 7 | { money } 8 | ~ money += 100 9 | { money } 10 | ~ money -= 50 11 | { money } 12 | ~ money-- 13 | { money } 14 | ~ money = 500 15 | ~ temp coins = 20 16 | 17 | Hello, { name }! Do you have { money } bucks? { coins } pennies may be? 18 | { has_knife: Nope| Yeap}. -------------------------------------------------------------------------------- /test/units/loop/2-3.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | >) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Tell me about the security system.' 7 | 'It's ancient,' the guard assures you. 'Old as coal.' 8 | 9 | 1) 'Can I get a uniform from somewhere?' 10 | 2) 'Are there dogs?' 11 | >) Enough talking 12 | 13 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/functions.ink: -------------------------------------------------------------------------------- 1 | {foo(): ->continue} 2 | something wrong 1 3 | 4 | -(continue) 5 | {boo(): 6 | something wrong 2 7 | } 8 | 9 | sum: {test(2, 3)} 10 | 11 | -> END 12 | 13 | === function test(a, b) === 14 | first param is {a} 15 | second param is {b} 16 | ~return a + b 17 | 18 | === function foo() === 19 | ~return true 20 | 21 | === function boo() === 22 | ~return 23 | 24 | -------------------------------------------------------------------------------- /test/units/choices-conditional.ink: -------------------------------------------------------------------------------- 1 | -> door 2 | 3 | === door === 4 | * { not france.paris } [Go to Paris] -> france.paris 5 | * { not england.london } [Go to London] -> england.london 6 | * { france || england } [Go home] 7 | Sweet home... -> END 8 | 9 | === france === 10 | = paris 11 | Hello Paris! 12 | -> door 13 | 14 | === england === 15 | = london 16 | Hello London! 17 | -> door -------------------------------------------------------------------------------- /test/units/nesting/1-2.txt: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | 3 | >) ... and I could contain myself no longer. 4 | 2) ... but I said nothing 5 | 6 | ... and I could contain myself no longer. 7 | 'What is the purpose of our journey, Monsieur?' 8 | 'A wager,' he replied. 9 | 10 | 1) 'A wager!' 11 | >) 'Ah.' 12 | 13 | 'Ah,' I replied, uncertain what I thought. 14 | After that, we passed the day in silence. -------------------------------------------------------------------------------- /test/units/choices-fallback.ink: -------------------------------------------------------------------------------- 1 | -> find_help 2 | 3 | === find_help === 4 | 5 | You search desperately for a friendly face in the crowd. 6 | * The woman in the hat[?] pushes you roughly aside. -> find_help 7 | * The man with the briefcase[?] looks disgusted as you stumble past him. -> find_help 8 | * -> 9 | But it is too late: you collapse onto the station platform. This is the end. 10 | -> END -------------------------------------------------------------------------------- /test/units/loop/3-3.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | >) 'Are there dogs?' 5 | 6 | 'Are there dogs?' 7 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 8 | 9 | 1) 'Can I get a uniform from somewhere?' 10 | 2) 'Tell me about the security system.' 11 | >) Enough talking 12 | 13 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/labels-choices/2-2-1.txt: -------------------------------------------------------------------------------- 1 | The guard frowns at you. 2 | 3 | 1) Greet him 4 | >) 'Get out of my way.' 5 | 6 | 'Get out of my way,' you tell the guard. 7 | 'Hmm,' replies the guard. 8 | 9 | 1) 'Hmm?' 10 | >) Shove him aside 11 | 12 | You shove him sharply. He stares in reply, and draws his sword! 13 | 14 | >) Throw rock at guard 15 | 2) Throw sand at guard 16 | 17 | You hurl a rock at the guard. -------------------------------------------------------------------------------- /test/units/labels-choices/2-2-2.txt: -------------------------------------------------------------------------------- 1 | The guard frowns at you. 2 | 3 | 1) Greet him 4 | >) 'Get out of my way.' 5 | 6 | 'Get out of my way,' you tell the guard. 7 | 'Hmm,' replies the guard. 8 | 9 | 1) 'Hmm?' 10 | >) Shove him aside 11 | 12 | You shove him sharply. He stares in reply, and draws his sword! 13 | 14 | 1) Throw rock at guard 15 | >) Throw sand at guard 16 | 17 | You hurl a handful of sand at the guard. -------------------------------------------------------------------------------- /test/units/loop/1-3.txt: -------------------------------------------------------------------------------- 1 | 2 | >) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 7 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 8 | 9 | 1) 'Tell me about the security system.' 10 | 2) 'Are there dogs?' 11 | >) Enough talking 12 | 13 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/stitches.ink: -------------------------------------------------------------------------------- 1 | We boarded the train, but where? 2 | * [First class] -> the_orient_express.in_first_class 3 | * [Travel in the guard's van] 4 | -> the_orient_express.in_the_guards_van 5 | 6 | === the_orient_express === 7 | = in_first_class 8 | I settled my master. 9 | * [Move to third class] 10 | -> in_third_class 11 | 12 | = in_third_class 13 | I put myself in third. 14 | 15 | = in_the_guards_van 16 | Whoosh.. -------------------------------------------------------------------------------- /test/units/choices-sticky/1-1-1-1-1.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Eat another donut 3 | 2) Get off the couch 4 | 5 | You eat another donut. 6 | 7 | >) Ok, is it fine? 8 | 2) Ok, it's fine! 9 | 10 | Ok, is it fine? 11 | 12 | >) Eat another donut 13 | 2) Get off the couch 14 | 15 | You eat another donut. 16 | 17 | >) Ok, it's fine! 18 | 19 | Ok, it's fine! 20 | 21 | >) Eat another donut 22 | 2) Get off the couch 23 | 24 | You eat another donut. -------------------------------------------------------------------------------- /test/units/choices-sticky/1-2-1-1-1.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Eat another donut 3 | 2) Get off the couch 4 | 5 | You eat another donut. 6 | 7 | 1) Ok, is it fine? 8 | >) Ok, it's fine! 9 | 10 | Ok, it's fine! 11 | 12 | >) Eat another donut 13 | 2) Get off the couch 14 | 15 | You eat another donut. 16 | 17 | >) Ok, is it fine? 18 | 19 | Ok, is it fine? 20 | 21 | >) Eat another donut 22 | 2) Get off the couch 23 | 24 | You eat another donut. -------------------------------------------------------------------------------- /example-defold/example.font: -------------------------------------------------------------------------------- 1 | font: "/builtins/fonts/vera_mo_bd.ttf" 2 | material: "/builtins/fonts/font.material" 3 | size: 32 4 | antialias: 1 5 | alpha: 1.0 6 | outline_alpha: 0.0 7 | outline_width: 0.0 8 | shadow_alpha: 0.0 9 | shadow_blur: 0 10 | shadow_x: 0.0 11 | shadow_y: 0.0 12 | extra_characters: "" 13 | output_format: TYPE_BITMAP 14 | all_chars: false 15 | cache_width: 0 16 | cache_height: 0 17 | render_mode: MODE_SINGLE_LAYER 18 | -------------------------------------------------------------------------------- /test/units/comments.ink: -------------------------------------------------------------------------------- 1 | "What do you make of this?" she asked. 2 | 3 | // A simple comment 4 | 5 | "I couldn't possibly comment," I replied. // A simple comment after text 6 | 7 | /* 8 | ... or an unlimited block of text 9 | */ 10 | 11 | Before comment ... /* A comment */ ... and after. Must be in one line, but right now it's known limitation. 12 | 13 | TODO: This TODO will not be printed. 14 | 15 | Text before TODO. TODO: This TODO will be printed. -------------------------------------------------------------------------------- /test/runtime/observing.lua: -------------------------------------------------------------------------------- 1 | local narrator, describe, it, assert = ... 2 | 3 | local content = [[ 4 | VAR mood = "sadly" 5 | ~ mood = "sunny" 6 | ]] 7 | 8 | local book = narrator.parse_content(content) 9 | local story = narrator.init_story(book) 10 | 11 | local mood 12 | 13 | story:observe('mood', function(value) 14 | mood = value 15 | end) 16 | 17 | story:begin() 18 | 19 | it('Sunny mood.', function() 20 | assert.equal(mood, 'sunny') 21 | end) -------------------------------------------------------------------------------- /test/units/choices-sticky/1-1-1-1-2.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Eat another donut 3 | 2) Get off the couch 4 | 5 | You eat another donut. 6 | 7 | >) Ok, is it fine? 8 | 2) Ok, it's fine! 9 | 10 | Ok, is it fine? 11 | 12 | >) Eat another donut 13 | 2) Get off the couch 14 | 15 | You eat another donut. 16 | 17 | >) Ok, it's fine! 18 | 19 | Ok, it's fine! 20 | 21 | 1) Eat another donut 22 | >) Get off the couch 23 | 24 | You struggle up off the couch to go and compose epic poetry. -------------------------------------------------------------------------------- /test/units/choices-sticky/1-2-1-1-2.txt: -------------------------------------------------------------------------------- 1 | 2 | >) Eat another donut 3 | 2) Get off the couch 4 | 5 | You eat another donut. 6 | 7 | 1) Ok, is it fine? 8 | >) Ok, it's fine! 9 | 10 | Ok, it's fine! 11 | 12 | >) Eat another donut 13 | 2) Get off the couch 14 | 15 | You eat another donut. 16 | 17 | >) Ok, is it fine? 18 | 19 | Ok, is it fine? 20 | 21 | 1) Eat another donut 22 | >) Get off the couch 23 | 24 | You struggle up off the couch to go and compose epic poetry. -------------------------------------------------------------------------------- /test/units/queries.ink: -------------------------------------------------------------------------------- 1 | SEED = 81 { SEED_RANDOM(81) } 2 | RANDOM(1, 100) = { RANDOM(1, 100) } 3 | POW(2,4) = { POW(2, 4) } 4 | INT(1.45) = { INT(1.45) } 5 | FLOOR(2.4) = { FLOOR(2.4) } 6 | FLOAT(3) = { FLOAT(3) } 7 | 8 | * { CHOICE_COUNT() == 0 } [Choice 1] 9 | 10 | { "Yes, please." == "Yes, please." : True! | False! } 11 | { "Yes, please." != "Yes, please." : True! | False! } 12 | { "Yes, please" ? "ease" : True! | False! } 13 | { "Yes, please" !? "ease" : True! | False! } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Busted", 6 | "command": "busted", 7 | "args": [ 8 | "${workspaceFolder}/test/run.lua" 9 | ], 10 | "type": "shell", 11 | "problemMatcher": [], 12 | "group": { 13 | "kind": "build", 14 | "isDefault": true 15 | } 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /test/units/tunnels.ink: -------------------------------------------------------------------------------- 1 | The dark grass is soft under your feet. 2 | {1: 3 | -> sleep_here -> wake_here -> 4 | 5 | -else: 6 | wtf 7 | } 8 | 9 | ->move_on-> 10 | 11 | ->end 12 | - (end) 13 | 14 | -> END 15 | 16 | === wake_here === 17 | You wake as the sun rises. 18 | ->-> 19 | 20 | === sleep_here === 21 | You lie down and try to close your eyes. 22 | -> dream 23 | 24 | === dream === 25 | You dream about the dream. 26 | ->-> 27 | 28 | === move_on 29 | It is time to move on. ->-> -------------------------------------------------------------------------------- /test/units/labels-nested.ink: -------------------------------------------------------------------------------- 1 | -> dialog_start 2 | ==dialog_start== 3 | 4 | * [Tell me about the mister] 5 | - - (dima) 6 | - - His name is Dima. 7 | * * {not vika} [Tell me about Vika] 8 | -> vika 9 | * * [Finish conversation] 10 | -> stop_dialog 11 | 12 | * [Tell me about the missis] 13 | - - (vika) 14 | Her name is Vika. 15 | * * {not dima} [Tell me about Dima] 16 | -> dima 17 | * * [Finish conversation] 18 | -> stop_dialog 19 | 20 | ==stop_dialog== 21 | That's all. 22 | -> END -------------------------------------------------------------------------------- /test/units/conditions-inline.ink: -------------------------------------------------------------------------------- 1 | VAR mood = 10 2 | VAR midnight = true 3 | 4 | -> simple 5 | 6 | === simple 7 | Simple condition: { mood > 20 : Hmm... { mood > 40 : Wow! } I was feeling positive enough. } Okay. 8 | ~ mood += 20 9 | { mood > 50 : -> complex | -> simple } 10 | 11 | === complex 12 | Complex condition with midnight. { midnight : Wow! { midnight : Nice! | Bad! } | { not midnight : Very nice! | Very bad! } This is the end. } 13 | { midnight != true : -> END } 14 | ~ midnight = false 15 | -> complex -------------------------------------------------------------------------------- /test/units/choices-fallback/2-1.txt: -------------------------------------------------------------------------------- 1 | You search desperately for a friendly face in the crowd. 2 | 3 | 1) The woman in the hat? 4 | >) The man with the briefcase? 5 | 6 | The man with the briefcase looks disgusted as you stumble past him. You search desperately for a friendly face in the crowd. 7 | 8 | >) The woman in the hat? 9 | 10 | The woman in the hat pushes you roughly aside. You search desperately for a friendly face in the crowd. 11 | But it is too late: you collapse onto the station platform. This is the end. -------------------------------------------------------------------------------- /test/units/choices-fallback/1-1.txt: -------------------------------------------------------------------------------- 1 | You search desperately for a friendly face in the crowd. 2 | 3 | >) The woman in the hat? 4 | 2) The man with the briefcase? 5 | 6 | The woman in the hat pushes you roughly aside. You search desperately for a friendly face in the crowd. 7 | 8 | >) The man with the briefcase? 9 | 10 | The man with the briefcase looks disgusted as you stumble past him. You search desperately for a friendly face in the crowd. 11 | But it is too late: you collapse onto the station platform. This is the end. -------------------------------------------------------------------------------- /test/units/loop/2-2-2.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | >) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Tell me about the security system.' 7 | 'It's ancient,' the guard assures you. 'Old as coal.' 8 | 9 | 1) 'Can I get a uniform from somewhere?' 10 | >) 'Are there dogs?' 11 | 3) Enough talking 12 | 13 | 'Are there dogs?' 14 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 15 | 16 | 1) 'Can I get a uniform from somewhere?' 17 | >) Enough talking 18 | 19 | You thank the guard, and move away. -------------------------------------------------------------------------------- /game.project: -------------------------------------------------------------------------------- 1 | [bootstrap] 2 | main_collection = /example-defold/examlpe.collectionc 3 | render = /builtins/render/default.renderc 4 | 5 | [script] 6 | shared_state = 1 7 | 8 | [display] 9 | width = 640 10 | height = 640 11 | 12 | [android] 13 | input_method = HiddenInputField 14 | 15 | [project] 16 | title = Narrator 17 | custom_resources = example-defold/book.ink 18 | dependencies#0 = https://github.com/astrochili/defold-lpeg/archive/1.0.4.zip 19 | 20 | [library] 21 | include_dirs = narrator 22 | 23 | [input] 24 | game_binding = /builtins/input/all.input_bindingc 25 | 26 | -------------------------------------------------------------------------------- /test/units/loop/3-2-2.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | >) 'Are there dogs?' 5 | 6 | 'Are there dogs?' 7 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 8 | 9 | 1) 'Can I get a uniform from somewhere?' 10 | >) 'Tell me about the security system.' 11 | 3) Enough talking 12 | 13 | 'Tell me about the security system.' 14 | 'It's ancient,' the guard assures you. 'Old as coal.' 15 | 16 | 1) 'Can I get a uniform from somewhere?' 17 | >) Enough talking 18 | 19 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/loop/1-1-2.txt: -------------------------------------------------------------------------------- 1 | 2 | >) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 7 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 8 | 9 | >) 'Tell me about the security system.' 10 | 2) 'Are there dogs?' 11 | 3) Enough talking 12 | 13 | 'Tell me about the security system.' 14 | 'It's ancient,' the guard assures you. 'Old as coal.' 15 | 16 | 1) 'Are there dogs?' 17 | >) Enough talking 18 | 19 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/loop/2-1-2.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | >) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Tell me about the security system.' 7 | 'It's ancient,' the guard assures you. 'Old as coal.' 8 | 9 | >) 'Can I get a uniform from somewhere?' 10 | 2) 'Are there dogs?' 11 | 3) Enough talking 12 | 13 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 14 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 15 | 16 | 1) 'Are there dogs?' 17 | >) Enough talking 18 | 19 | You thank the guard, and move away. -------------------------------------------------------------------------------- /narrator/enums.lua: -------------------------------------------------------------------------------- 1 | local enums = { 2 | 3 | ---Bump it when the state structure is changed 4 | engine_version = 2, 5 | 6 | ---@enum Narrator.ItemType 7 | item = { 8 | text = 1, 9 | alts = 2, 10 | choice = 3, 11 | condition = 4, 12 | variable = 5 13 | }, 14 | 15 | ---@enum Narrator.Sequence 16 | sequence = { 17 | cycle = 1, 18 | stopping = 2, 19 | once = 3 20 | }, 21 | 22 | ---@enum Narrator.ReadMode 23 | read_mode = { 24 | text = 1, 25 | choices = 2, 26 | gathers = 3, 27 | quit = 4 28 | } 29 | 30 | } 31 | 32 | return enums -------------------------------------------------------------------------------- /test/units/lists-basic.txt: -------------------------------------------------------------------------------- 1 | We have two cats. 2 | Today is Monday. Tomorrow is Tuesday. 3 | ... 4 | Hm, kettle is cold and pot is cold. 5 | Now kettle is boiling and pot is cold. 6 | ... 7 | Binary values are 1 and 0 8 | ... 9 | The lecturer's voice becomes medium. 10 | The lecturer has 2 notches still available to him. 11 | The murmuring gets louder. 12 | The lecturer's voice becomes loud. 13 | The lecturer has 1 notches still available to him. 14 | The murmuring gets louder. 15 | The lecturer's voice becomes deafening. 16 | The lecturer has 0 notches still available to him. 17 | The murmuring gets louder. -------------------------------------------------------------------------------- /test/units/loop/1-2-2.txt: -------------------------------------------------------------------------------- 1 | 2 | >) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 7 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 8 | 9 | 1) 'Tell me about the security system.' 10 | >) 'Are there dogs?' 11 | 3) Enough talking 12 | 13 | 'Are there dogs?' 14 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 15 | 16 | 1) 'Tell me about the security system.' 17 | >) Enough talking 18 | 19 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/loop/3-1-2.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | >) 'Are there dogs?' 5 | 6 | 'Are there dogs?' 7 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 8 | 9 | >) 'Can I get a uniform from somewhere?' 10 | 2) 'Tell me about the security system.' 11 | 3) Enough talking 12 | 13 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 14 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 15 | 16 | 1) 'Tell me about the security system.' 17 | >) Enough talking 18 | 19 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/lists-queries.txt: -------------------------------------------------------------------------------- 1 | Normal: Smith, Jones 2 | Inverted: Carter, Braithwaite 3 | Range: Carter 4 | ... 5 | The new president has at least one desirable quality. A cold nepotism. 6 | Correction, the new president has only one desirable quality. It's the scary one. 7 | ... 8 | Robin is all but forgotten. A champagne glass lies discarded on the floor. 9 | Alfred is here, standing quietly in a corner. Batman's presence dominates all. On one table, a headline blares out WHO IS THE BATMAN? AND *WHO* IS HIS BARELY-REMEMBERED ASSISTANT? 10 | ... 11 | one, a, two, b, three, c 12 | 3 13 | a 14 | c 15 | 0 16 | a, c 17 | 1 18 | 0 19 | one, two, b -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Debug", 5 | "type": "lua-local", 6 | "request": "launch", 7 | "program": { 8 | "lua": "lua", 9 | "file": "${workspaceFolder}/debug.lua" 10 | } 11 | }, 12 | { 13 | "name": "Busted", 14 | "type": "lua-local", 15 | "request": "launch", 16 | "program": { 17 | "command": "busted" 18 | }, 19 | "args": [ 20 | "${workspaceFolder}/test/run.lua", 21 | ], 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /test/units/branching.ink: -------------------------------------------------------------------------------- 1 | -> back_in_london 2 | 3 | === back_in_london === 4 | 5 | We arrived into London at 9.45pm exactly. 6 | * 'There is not a moment to lose!'[] I declared. 7 | -> hurry_outside 8 | * 'Monsieur, let us savour this moment!'[] I declared. 9 | My master clouted me firmly around the head and dragged me out of the door. 10 | -> dragged_outside 11 | * [We hurried home] -> hurry_outside 12 | 13 | === hurry_outside === 14 | We hurried home to Savile Row -> as_fast_as_we_could 15 | 16 | === dragged_outside === 17 | He insisted that we hurried home to Savile Row 18 | -> as_fast_as_we_could 19 | 20 | === as_fast_as_we_could === 21 | <> as fast as we could. -------------------------------------------------------------------------------- /test/runtime/continue.lua: -------------------------------------------------------------------------------- 1 | local narrator, describe, it, assert = ... 2 | 3 | local content = [[ 4 | Line 1 5 | Line 2 6 | Line 3 7 | Line 4 8 | Line 5 9 | Line 6 10 | ]] 11 | 12 | local book = narrator.parse_content(content) 13 | local story = narrator.init_story(book) 14 | 15 | story:begin() 16 | 17 | it('Get one paragraph.', function() 18 | local paragraph = story:continue(1) 19 | assert.equal(paragraph.text, 'Line 1') 20 | end) 21 | 22 | it('Get two paragraphs.', function() 23 | local paragraphs = story:continue(2) 24 | assert.equal(#paragraphs, 2) 25 | end) 26 | 27 | it('Get remain paragraphs.', function() 28 | local paragraphs = story:continue() 29 | assert.equal(#paragraphs, 3) 30 | end) -------------------------------------------------------------------------------- /test/units/loop.ink: -------------------------------------------------------------------------------- 1 | - (opts) 2 | * 'Can I get a uniform from somewhere?'[] you ask the cheerful guard. 3 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 4 | * 'Tell me about the security system.' 5 | 'It's ancient,' the guard assures you. 'Old as coal.' 6 | * 'Are there dogs?' 7 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 8 | // We require the player to ask at least one question 9 | * {loop} [Enough talking] 10 | -> done 11 | - (loop) 12 | // loop a few times before the guard gets bored 13 | { -> opts | -> opts | } 14 | He scratches his head. 15 | 'Well, can't stand around talking all day,' he declares. 16 | - (done) 17 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/runtime/set-get.lua: -------------------------------------------------------------------------------- 1 | local narrator, describe, it, assert = ... 2 | 3 | local content = [[ 4 | VAR x = 1 5 | * [Change x to 1] 6 | * [Change x to 2] 7 | * [Change x to 3] 8 | x = { x } 9 | ]] 10 | 11 | local book = narrator.parse_content(content) 12 | local story = narrator.init_story(book) 13 | 14 | story:begin() 15 | 16 | it('The x is equal to 1.', function() 17 | local x = story.variables['x'] 18 | assert.equal(x, 1) 19 | end) 20 | 21 | it('The x is changed to 3.', function() 22 | local answer = 3 23 | story.variables['x'] = answer 24 | story:choose(answer) 25 | 26 | local paragraphs = story:continue() 27 | assert.equal(#paragraphs, 1) 28 | assert.equal(paragraphs[1].text, 'x = 3') 29 | end) 30 | 31 | -------------------------------------------------------------------------------- /test/units/nesting/1-1-2-1.txt: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | 3 | >) ... and I could contain myself no longer. 4 | 2) ... but I said nothing 5 | 6 | ... and I could contain myself no longer. 7 | 'What is the purpose of our journey, Monsieur?' 8 | 'A wager,' he replied. 9 | 10 | >) 'A wager!' 11 | 2) 'Ah.' 12 | 13 | 'A wager!' I returned. 14 | He nodded. 15 | 16 | 1) 'But surely that is foolishness!' 17 | >) 'A most serious matter then!' 18 | 19 | 'A most serious matter then!' 20 | He nodded again. 21 | 22 | >) 'But can we win?' 23 | 2) 'A modest wager, I trust?' 24 | 3) I asked nothing further of him then. 25 | 26 | 'But can we win?' 27 | 'That is what we will endeavour to find out,' he answered. 28 | After that, we passed the day in silence. -------------------------------------------------------------------------------- /test/units/nesting/1-1-1-1.txt: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | 3 | >) ... and I could contain myself no longer. 4 | 2) ... but I said nothing 5 | 6 | ... and I could contain myself no longer. 7 | 'What is the purpose of our journey, Monsieur?' 8 | 'A wager,' he replied. 9 | 10 | >) 'A wager!' 11 | 2) 'Ah.' 12 | 13 | 'A wager!' I returned. 14 | He nodded. 15 | 16 | >) 'But surely that is foolishness!' 17 | 2) 'A most serious matter then!' 18 | 19 | 'But surely that is foolishness!' 20 | He nodded again. 21 | 22 | >) 'But can we win?' 23 | 2) 'A modest wager, I trust?' 24 | 3) I asked nothing further of him then. 25 | 26 | 'But can we win?' 27 | 'That is what we will endeavour to find out,' he answered. 28 | After that, we passed the day in silence. -------------------------------------------------------------------------------- /test/units/nesting/1-1-1-2.txt: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | 3 | >) ... and I could contain myself no longer. 4 | 2) ... but I said nothing 5 | 6 | ... and I could contain myself no longer. 7 | 'What is the purpose of our journey, Monsieur?' 8 | 'A wager,' he replied. 9 | 10 | >) 'A wager!' 11 | 2) 'Ah.' 12 | 13 | 'A wager!' I returned. 14 | He nodded. 15 | 16 | >) 'But surely that is foolishness!' 17 | 2) 'A most serious matter then!' 18 | 19 | 'But surely that is foolishness!' 20 | He nodded again. 21 | 22 | 1) 'But can we win?' 23 | >) 'A modest wager, I trust?' 24 | 3) I asked nothing further of him then. 25 | 26 | 'A modest wager, I trust?' 27 | 'Twenty thousand pounds,' he replied, quite flatly. 28 | After that, we passed the day in silence. -------------------------------------------------------------------------------- /test/units/nesting/1-1-2-2.txt: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | 3 | >) ... and I could contain myself no longer. 4 | 2) ... but I said nothing 5 | 6 | ... and I could contain myself no longer. 7 | 'What is the purpose of our journey, Monsieur?' 8 | 'A wager,' he replied. 9 | 10 | >) 'A wager!' 11 | 2) 'Ah.' 12 | 13 | 'A wager!' I returned. 14 | He nodded. 15 | 16 | 1) 'But surely that is foolishness!' 17 | >) 'A most serious matter then!' 18 | 19 | 'A most serious matter then!' 20 | He nodded again. 21 | 22 | 1) 'But can we win?' 23 | >) 'A modest wager, I trust?' 24 | 3) I asked nothing further of him then. 25 | 26 | 'A modest wager, I trust?' 27 | 'Twenty thousand pounds,' he replied, quite flatly. 28 | After that, we passed the day in silence. -------------------------------------------------------------------------------- /example-defold/examlpe.collection: -------------------------------------------------------------------------------- 1 | name: "main" 2 | scale_along_z: 0 3 | embedded_instances { 4 | id: "example" 5 | data: "components {\n" 6 | " id: \"example\"\n" 7 | " component: \"/example-defold/example.gui\"\n" 8 | " position {\n" 9 | " x: 0.0\n" 10 | " y: 0.0\n" 11 | " z: 0.0\n" 12 | " }\n" 13 | " rotation {\n" 14 | " x: 0.0\n" 15 | " y: 0.0\n" 16 | " z: 0.0\n" 17 | " w: 1.0\n" 18 | " }\n" 19 | " property_decls {\n" 20 | " }\n" 21 | "}\n" 22 | "" 23 | position { 24 | x: 0.0 25 | y: 0.0 26 | z: 0.0 27 | } 28 | rotation { 29 | x: 0.0 30 | y: 0.0 31 | z: 0.0 32 | w: 1.0 33 | } 34 | scale3 { 35 | x: 1.0 36 | y: 1.0 37 | z: 1.0 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/units/nesting/1-1-2-3.txt: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | 3 | >) ... and I could contain myself no longer. 4 | 2) ... but I said nothing 5 | 6 | ... and I could contain myself no longer. 7 | 'What is the purpose of our journey, Monsieur?' 8 | 'A wager,' he replied. 9 | 10 | >) 'A wager!' 11 | 2) 'Ah.' 12 | 13 | 'A wager!' I returned. 14 | He nodded. 15 | 16 | 1) 'But surely that is foolishness!' 17 | >) 'A most serious matter then!' 18 | 19 | 'A most serious matter then!' 20 | He nodded again. 21 | 22 | 1) 'But can we win?' 23 | 2) 'A modest wager, I trust?' 24 | >) I asked nothing further of him then. 25 | 26 | I asked nothing further of him then, and after a final, polite cough, he offered nothing more to me. After that, we passed the day in silence. -------------------------------------------------------------------------------- /test/units/nesting/1-1-1-3.txt: -------------------------------------------------------------------------------- 1 | I looked at Monsieur Fogg 2 | 3 | >) ... and I could contain myself no longer. 4 | 2) ... but I said nothing 5 | 6 | ... and I could contain myself no longer. 7 | 'What is the purpose of our journey, Monsieur?' 8 | 'A wager,' he replied. 9 | 10 | >) 'A wager!' 11 | 2) 'Ah.' 12 | 13 | 'A wager!' I returned. 14 | He nodded. 15 | 16 | >) 'But surely that is foolishness!' 17 | 2) 'A most serious matter then!' 18 | 19 | 'But surely that is foolishness!' 20 | He nodded again. 21 | 22 | 1) 'But can we win?' 23 | 2) 'A modest wager, I trust?' 24 | >) I asked nothing further of him then. 25 | 26 | I asked nothing further of him then, and after a final, polite cough, he offered nothing more to me. After that, we passed the day in silence. -------------------------------------------------------------------------------- /test/units/loop/1-1-1.txt: -------------------------------------------------------------------------------- 1 | 2 | >) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 7 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 8 | 9 | >) 'Tell me about the security system.' 10 | 2) 'Are there dogs?' 11 | 3) Enough talking 12 | 13 | 'Tell me about the security system.' 14 | 'It's ancient,' the guard assures you. 'Old as coal.' 15 | 16 | >) 'Are there dogs?' 17 | 2) Enough talking 18 | 19 | 'Are there dogs?' 20 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 21 | He scratches his head. 22 | 'Well, can't stand around talking all day,' he declares. 23 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/loop/2-1-1.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | >) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Tell me about the security system.' 7 | 'It's ancient,' the guard assures you. 'Old as coal.' 8 | 9 | >) 'Can I get a uniform from somewhere?' 10 | 2) 'Are there dogs?' 11 | 3) Enough talking 12 | 13 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 14 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 15 | 16 | >) 'Are there dogs?' 17 | 2) Enough talking 18 | 19 | 'Are there dogs?' 20 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 21 | He scratches his head. 22 | 'Well, can't stand around talking all day,' he declares. 23 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/constants.ink: -------------------------------------------------------------------------------- 1 | CONST STRING_EXAMPLE = "This is the string constant" 2 | CONST BOOLEAN_EXAMLPE_TRUE = true 3 | CONST BOOLEAN_EXAMLPE_FALSE = false 4 | 5 | CONST LOBBY = 1 6 | CONST STAIRCASE = 2 7 | CONST HALLWAY = 3 8 | CONST HELD_BY_AGENT = -1 9 | 10 | VAR secret_agent_location = LOBBY 11 | VAR suitcase_location = HALLWAY 12 | 13 | { STRING_EXAMPLE }. One is { BOOLEAN_EXAMLPE_TRUE }. Zero is { BOOLEAN_EXAMLPE_FALSE }. 14 | 15 | -> report_progress 16 | 17 | === report_progress === 18 | { secret_agent_location == suitcase_location: 19 | The secret agent grabs the suitcase! 20 | ~ suitcase_location = HELD_BY_AGENT 21 | 22 | - secret_agent_location < suitcase_location: 23 | The secret agent moves forward. 24 | ~ secret_agent_location++ 25 | -> report_progress 26 | } -------------------------------------------------------------------------------- /test/units/loop/1-2-1.txt: -------------------------------------------------------------------------------- 1 | 2 | >) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 7 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 8 | 9 | 1) 'Tell me about the security system.' 10 | >) 'Are there dogs?' 11 | 3) Enough talking 12 | 13 | 'Are there dogs?' 14 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 15 | 16 | >) 'Tell me about the security system.' 17 | 2) Enough talking 18 | 19 | 'Tell me about the security system.' 20 | 'It's ancient,' the guard assures you. 'Old as coal.' 21 | He scratches his head. 22 | 'Well, can't stand around talking all day,' he declares. 23 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/loop/2-2-1.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | >) 'Tell me about the security system.' 4 | 3) 'Are there dogs?' 5 | 6 | 'Tell me about the security system.' 7 | 'It's ancient,' the guard assures you. 'Old as coal.' 8 | 9 | 1) 'Can I get a uniform from somewhere?' 10 | >) 'Are there dogs?' 11 | 3) Enough talking 12 | 13 | 'Are there dogs?' 14 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 15 | 16 | >) 'Can I get a uniform from somewhere?' 17 | 2) Enough talking 18 | 19 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 20 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 21 | He scratches his head. 22 | 'Well, can't stand around talking all day,' he declares. 23 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/alts-blocks.txt: -------------------------------------------------------------------------------- 1 | === 1 2 | At the table, I drew a card. I entered the casino. 3 | === 2 4 | At the table, I drew a card. Okay. I entered the casino again. 5 | === 3 6 | At the table, I drew a card. 3. Once more, I went inside. 7 | === 4 8 | At the table, I drew a card. 4. Once more, I went inside. 9 | === 5 10 | I held my breath. 11 | === 6 12 | I waited impatiently. 13 | === 7 14 | I paused. 15 | === 8 16 | I held my breath. 17 | === 9 18 | Would my luck hold? 19 | === 10 20 | Could I win the hand? 21 | === 11 22 | === 12 23 | === 13 24 | At the table, I drew a card. Ace of Hearts. 25 | === 14 26 | At the table, I drew a card. King of Spades. 27 | === 15 28 | At the table, I drew a card. 2 of Diamonds. 29 | 'You lose this time!' crowed the croupier. 30 | === 16 31 | Okay. Ace of Hearts again. -------------------------------------------------------------------------------- /test/units/loop/3-1-1.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | >) 'Are there dogs?' 5 | 6 | 'Are there dogs?' 7 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 8 | 9 | >) 'Can I get a uniform from somewhere?' 10 | 2) 'Tell me about the security system.' 11 | 3) Enough talking 12 | 13 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 14 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 15 | 16 | >) 'Tell me about the security system.' 17 | 2) Enough talking 18 | 19 | 'Tell me about the security system.' 20 | 'It's ancient,' the guard assures you. 'Old as coal.' 21 | He scratches his head. 22 | 'Well, can't stand around talking all day,' he declares. 23 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/loop/3-2-1.txt: -------------------------------------------------------------------------------- 1 | 2 | 1) 'Can I get a uniform from somewhere?' 3 | 2) 'Tell me about the security system.' 4 | >) 'Are there dogs?' 5 | 6 | 'Are there dogs?' 7 | 'Hundreds,' the guard answers, with a toothy grin. 'Hungry devils, too.' 8 | 9 | 1) 'Can I get a uniform from somewhere?' 10 | >) 'Tell me about the security system.' 11 | 3) Enough talking 12 | 13 | 'Tell me about the security system.' 14 | 'It's ancient,' the guard assures you. 'Old as coal.' 15 | 16 | >) 'Can I get a uniform from somewhere?' 17 | 2) Enough talking 18 | 19 | 'Can I get a uniform from somewhere?' you ask the cheerful guard. 20 | 'Sure. In the locker.' He grins. 'Don't think it'll fit you, though.' 21 | He scratches his head. 22 | 'Well, can't stand around talking all day,' he declares. 23 | You thank the guard, and move away. -------------------------------------------------------------------------------- /test/units/nesting.ink: -------------------------------------------------------------------------------- 1 | - I looked at Monsieur Fogg 2 | * ... and I could contain myself no longer. 3 | 'What is the purpose of our journey, Monsieur?' 4 | 'A wager,' he replied. 5 | * * 'A wager!'[] I returned. 6 | He nodded. 7 | * * * 'But surely that is foolishness!' 8 | * * * 'A most serious matter then!' 9 | - - - He nodded again. 10 | * * * 'But can we win?' 11 | 'That is what we will endeavour to find out,' he answered. 12 | * * * 'A modest wager, I trust?' 13 | 'Twenty thousand pounds,' he replied, quite flatly. 14 | * * * I asked nothing further of him then[.], and after a final, polite cough, he offered nothing more to me. <> 15 | * * 'Ah[.'],' I replied, uncertain what I thought. 16 | - - After that, <> 17 | * ... but I said nothing[] and <> 18 | - we passed the day in silence. 19 | - -> END -------------------------------------------------------------------------------- /test/units/lists-operators.txt: -------------------------------------------------------------------------------- 1 | Cartwright == Cartwright 2 | Adams, Bernard == Adams, Bernard 3 | Empty == Empty 4 | Adams == Adams 5 | Adams, Eamonn == Adams, Eamonn 6 | Adams == Adams 7 | Adams, Eamonn, Denver == Adams, Denver, Eamonn 8 | Empty == Empty again! 9 | ... 10 | 2 doctors in surgery. 11 | First is Adams. 12 | Last is Cartwright. 13 | Random seed = 1 14 | A random doctor: Adams 15 | Working time! The surgery is open today. 16 | It's to late. Everyone has gone home. 17 | Dr Adams and Dr Bernard are having a loud argument in one corner. 18 | Dr Adams and Dr Bernard are having a hushed argument in one corner. 19 | At least Adams and Bernard aren't arguing. 20 | Dr Eamonn is polishing his glasses. 21 | Nope, Adams and Bernard are here. 22 | All the doctors (5): Adams, Bernard, Cartwright, Denver, Eamonn 23 | All the doctors again: Adams, Bernard, Cartwright, Denver, Eamonn 24 | Adams here! -------------------------------------------------------------------------------- /test/units/labels-choices.ink: -------------------------------------------------------------------------------- 1 | -> meet_guard 2 | 3 | === meet_guard === 4 | The guard frowns at you. 5 | 6 | * (greet) [Greet him] 7 | 'Greetings.' 8 | * (get_out) 'Get out of my way[.'],' you tell the guard. 9 | 10 | - 'Hmm,' replies the guard. 11 | 12 | * {greet} 'Having a nice day?' // only if you greeted him 13 | 14 | * 'Hmm?'[] you reply. 15 | 16 | * {get_out} [Shove him aside] // only if you threatened him 17 | You shove him sharply. He stares in reply, and draws his sword! 18 | -> fight_guard // this route diverts out of the weave 19 | 20 | - 'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?' 21 | 22 | 23 | === fight_guard === 24 | 25 | -> throw_something 26 | 27 | = throw_something 28 | * (rock) [Throw rock at guard] -> throw 29 | * (sand) [Throw sand at guard] -> throw 30 | 31 | = throw 32 | You hurl {throw_something.rock:a rock|a handful of sand} at the guard. -------------------------------------------------------------------------------- /stories/game.ink: -------------------------------------------------------------------------------- 1 | - I looked at Monsieur Fogg 2 | * ... and I could contain myself no longer. 3 | 'What is the purpose of our journey, Monsieur?' 4 | 'A wager,' he replied. 5 | * * 'A wager!'[] I returned. 6 | He nodded. 7 | * * * 'But surely that is foolishness!' 8 | * * * 'A most serious matter then!' 9 | - - - He nodded again. 10 | * * * 'But can we win?' 11 | 'That is what we will endeavour to find out,' he answered. 12 | * * * 'A modest wager, I trust?' 13 | 'Twenty thousand pounds,' he replied, quite flatly. 14 | * * * I asked nothing further of him then[.], and after a final, polite cough, he offered nothing more to me. <> 15 | * * 'Ah[.'],' I replied, uncertain what I thought. 16 | - - After that, <> 17 | * ... but I said nothing[] and <> 18 | - we passed the day in silence. 19 | - -> END -------------------------------------------------------------------------------- /example-defold/book.ink: -------------------------------------------------------------------------------- 1 | - I looked at Monsieur Fogg 2 | * ... and I could contain myself no longer. 3 | 'What is the purpose of our journey, Monsieur?' 4 | 'A wager,' he replied. 5 | * * 'A wager!'[] I returned. 6 | He nodded. 7 | * * * 'But surely that is foolishness!' 8 | * * * 'A most serious matter then!' 9 | - - - He nodded again. 10 | * * * 'But can we win?' 11 | 'That is what we will endeavour to find out,' he answered. 12 | * * * 'A modest wager, I trust?' 13 | 'Twenty thousand pounds,' he replied, quite flatly. 14 | * * * I asked nothing further of him then[.], and after a final, polite cough, he offered nothing more to me. <> 15 | * * 'Ah[.'],' I replied, uncertain what I thought. 16 | - - After that, <> 17 | * ... but I said nothing[] and <> 18 | - we passed the day in silence. 19 | - -> END -------------------------------------------------------------------------------- /debug.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Script for debugging in VSCode with 3 | -- Local Lua Debugger by Tom Blind (https://github.com/tomblind/local-lua-debugger-vscode) 4 | 5 | -- Dependencies 6 | local narrator = require('narrator.narrator') 7 | local bot = require('bot') 8 | 9 | -- Loading 10 | -- local book = require('stories.debug') 11 | -- local book = narrator.parseBook('Hello world!', { '=== one === \n text 1', '=== two === \n text 2' }) 12 | local book = narrator.parse_file('stories.debug', { save = false }) 13 | 14 | local story = narrator.init_story(book) 15 | local answers = { } 16 | 17 | -- Choice instructor for a bot 18 | local function instructor(choices, step) 19 | local answer = answers[step] 20 | if answer == nil then 21 | math.randomseed(os.time() * 10000000) 22 | answer = math.random(1, #choices) 23 | end 24 | return answer 25 | end 26 | 27 | -- Game 28 | print('--- Game started ---\n') 29 | bot.play(story, instructor, { print = true }) 30 | print('\n--- Game over ---') -------------------------------------------------------------------------------- /test/runtime/binding.lua: -------------------------------------------------------------------------------- 1 | local narrator, describe, it, assert = ... 2 | 3 | local content = [[ 4 | ~ beep() 5 | { sum(1, 2) } 6 | { did_solve_puzzle("labirint") } 7 | ]] 8 | 9 | local book = narrator.parse_content(content) 10 | local story = narrator.init_story(book) 11 | 12 | local is_beeped = false 13 | local puzzles = { } 14 | 15 | story:bind('beep', function() 16 | is_beeped = true 17 | end) 18 | 19 | story:bind('sum', function(x, y) 20 | return x + y 21 | end) 22 | 23 | story:bind('did_solve_puzzle', function(puzzle) 24 | puzzles[puzzle] = true 25 | end) 26 | 27 | story:begin() 28 | 29 | it('Was a beep?', function() 30 | assert.is_true(is_beeped) 31 | end) 32 | 33 | it('Sum is equal to 3.', function() 34 | local paragraphs = story:continue() 35 | assert.equal(#paragraphs, 1) 36 | assert.equal('3', paragraphs[1].text) 37 | end) 38 | 39 | it('Labirint is sovled.', function() 40 | local puzzle_is_solved = puzzles['labirint'] 41 | assert.is_true(puzzle_is_solved) 42 | end) -------------------------------------------------------------------------------- /narrator/annotations.lua: -------------------------------------------------------------------------------- 1 | ---@class Narrator.Book.Version 2 | ---@field engine number 3 | ---@field tree number 4 | 5 | ---@class Narrator.Book 6 | ---@field version Narrator.Book.Version 7 | ---@field inclusions string[] 8 | ---@field lists table 9 | ---@field constants table 10 | ---@field variables table 11 | ---@field params table 12 | ---@field tree table 13 | 14 | ---@class Narrator.ParsingParams 15 | ---@field save boolean Save a parsed book to the lua file 16 | 17 | ---@class Narrator.Paragraph 18 | ---@field text string 19 | ---@field tags string[]|nil 20 | 21 | ---@class Narrator.Choice 22 | ---@field text string 23 | ---@field tags string[]|nil 24 | 25 | ---@class Narrator.State 26 | ---@field version number 27 | ---@field temp table 28 | ---@field seeds table 29 | ---@field variables table 30 | ---@field params table|nil 31 | ---@field visits table 32 | ---@field current_path table 33 | ---@field paragraphs table 34 | ---@field choices table 35 | ---@field output table 36 | ---@field tunnels table|nil 37 | ---@field path table -------------------------------------------------------------------------------- /test/runtime/jumping.lua: -------------------------------------------------------------------------------- 1 | local narrator, describe, it, assert = ... 2 | 3 | local content = [[ 4 | A root line 5 | === knot 6 | A knot line 7 | = stitch 8 | A stitch line 9 | === somewhere 10 | - (label) A label line 11 | ]] 12 | 13 | local book = narrator.parse_content(content) 14 | local story = narrator.init_story(book) 15 | 16 | story:begin() 17 | 18 | local paragraphs = story:continue() 19 | 20 | it('Jump to label.', function() 21 | story:jump_to('somewhere.label') 22 | local paragraphs = story:continue() 23 | assert.equal(#paragraphs, 1) 24 | assert.equal(paragraphs[1].text, 'A label line') 25 | end) 26 | 27 | it('Jump to stitch.', function() 28 | story:jump_to('knot.stitch') 29 | local paragraphs = story:continue() 30 | assert.equal(#paragraphs, 1) 31 | assert.equal(paragraphs[1].text, 'A stitch line') 32 | end) 33 | 34 | it('Jump to knot.', function() 35 | story:jump_to('knot') 36 | local paragraphs = story:continue() 37 | assert.equal(#paragraphs, 1) 38 | assert.equal(paragraphs[1].text, 'A knot line') 39 | end) -------------------------------------------------------------------------------- /test/units/alts-inline.ink: -------------------------------------------------------------------------------- 1 | VAR counter = 0 2 | -> coffee 3 | 4 | = coffee 5 | {I bought a coffee with my five-pound note.|I bought a second coffee for my friend.|I didn't have enough money to buy any more coffee.} 6 | ~ counter++ 7 | { counter < 4 : -> coffee | -> today } 8 | 9 | = today 10 | It was {&Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday} today. 11 | ~ counter++ 12 | { counter < 15 : -> today | -> joke} 13 | 14 | = joke 15 | He told me a joke. {!I laughed politely.|I smiled.|I grimaced.|I promised myself to not react again.} 16 | ~ counter++ 17 | { counter < 20 : -> joke | -> coin} 18 | 19 | = coin 20 | { SEED_RANDOM(20 - counter) } 21 | I tossed the coin. {~Heads|Tails}. 22 | ~ counter++ 23 | { counter < 22 : -> coin | -> lights} 24 | 25 | = lights 26 | I took a step forward. {!||Then the lights went out. -> dark } 27 | ~ counter++ 28 | { counter < 27 : -> lights | -> ratbear} 29 | 30 | = dark 31 | So dark... 32 | -> ratbear 33 | 34 | = ratbear 35 | The Ratbear {&{wastes no time and |}swipes|scratches } {&at you|into your {&leg|arm|cheek}}. 36 | ~ counter++ 37 | { counter < 32 : -> ratbear | -> END } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Roman Silin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /stories/game.lua: -------------------------------------------------------------------------------- 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={}} -------------------------------------------------------------------------------- /test/units/alts-inline.txt: -------------------------------------------------------------------------------- 1 | I bought a coffee with my five-pound note. 2 | I bought a second coffee for my friend. 3 | I didn't have enough money to buy any more coffee. 4 | I didn't have enough money to buy any more coffee. 5 | It was Monday today. 6 | It was Tuesday today. 7 | It was Wednesday today. 8 | It was Thursday today. 9 | It was Friday today. 10 | It was Saturday today. 11 | It was Sunday today. 12 | It was Monday today. 13 | It was Tuesday today. 14 | It was Wednesday today. 15 | It was Thursday today. 16 | He told me a joke. I laughed politely. 17 | He told me a joke. I smiled. 18 | He told me a joke. I grimaced. 19 | He told me a joke. I promised myself to not react again. 20 | He told me a joke. 21 | I tossed the coin. Heads. 22 | I tossed the coin. Tails. 23 | I took a step forward. 24 | I took a step forward. 25 | I took a step forward. Then the lights went out. So dark... 26 | The Ratbear wastes no time and swipes at you. 27 | The Ratbear scratches into your leg. 28 | The Ratbear swipes at you. 29 | The Ratbear scratches into your arm. 30 | The Ratbear swipes at you. 31 | The Ratbear scratches into your cheek. 32 | The Ratbear swipes at you. 33 | The Ratbear scratches into your leg. -------------------------------------------------------------------------------- /test/units/alts-blocks.ink: -------------------------------------------------------------------------------- 1 | VAR counter = 1 2 | -> casino 3 | 4 | = casino 5 | === { counter } 6 | At the table, I drew a card. <> 7 | { stopping: 8 | - 9 | I entered the casino. 10 | - Okay. <> 11 | I entered the casino again. 12 | - { counter }. <> 13 | 14 | Once more, I went inside. 15 | } 16 | ~ counter++ 17 | { counter < 5 : -> casino | -> cycle } 18 | 19 | = cycle 20 | === { counter } 21 | { cycle: 22 | - I held my breath. 23 | - I waited impatiently. 24 | - I paused. 25 | } 26 | ~ counter++ 27 | { counter < 9: -> cycle | -> once } 28 | 29 | = once 30 | === { counter } 31 | { once: 32 | - Would my luck hold? 33 | - Could I win the hand? 34 | } 35 | ~ counter++ 36 | { counter < 13 : -> once | -> shuffle } 37 | 38 | = shuffle 39 | === { counter } 40 | { SEED_RANDOM(counter - 13) } 41 | At the table, I drew a card. <> 42 | { shuffle: 43 | - Ace of Hearts. 44 | - King of Spades. 45 | - 2 of Diamonds. 46 | 'You lose this time!' crowed the croupier. 47 | } 48 | ~ counter++ 49 | { counter < 16 : -> shuffle | -> nested } 50 | 51 | = nested 52 | === { counter } 53 | { SEED_RANDOM(counter - 16) } 54 | { true: 55 | { shuffle: 56 | - Okay. Ace of Hearts again. 57 | - Okay. Ace of Hearts?! 58 | } 59 | } 60 | -> END -------------------------------------------------------------------------------- /test/units/conditions-switch.ink: -------------------------------------------------------------------------------- 1 | VAR foo = false 2 | VAR x = 0 3 | 4 | -> simple 5 | 6 | == simple 7 | {not foo: 8 | Hello! 9 | } 10 | { foo : 11 | True! -> choice 12 | - else: 13 | False! } 14 | ~ foo = not foo 15 | -> simple 16 | 17 | === choice 18 | 19 | { true : 20 | * [Answer] -> nested_inline 21 | } 22 | 23 | == nested_inline 24 | { 25 | - foo : { foo: True! | False! } 26 | Text here. 27 | - else: Badaboom! 28 | ...again. -> nested_block 29 | } 30 | ~ foo = not foo 31 | -> nested_inline 32 | 33 | 34 | == nested_block 35 | I love you. { true: 36 | And what about you? 37 | { 38 | - false: 39 | False! 40 | - true: 41 | * Choice -> tags 42 | } 43 | ... 44 | } Something. 45 | 46 | == tags 47 | { 48 | - not foo: 49 | text { true : x = { x } } #success 50 | - else: 51 | -> not_a_label 52 | } 53 | ~ foo = not foo 54 | -> tags 55 | 56 | == not_a_label 57 | { 58 | - foo: suc... 59 | success #success 60 | - x < 0: mid... 61 | middle 62 | text -> END 63 | - else: els... 64 | (notLabel) else 65 | -> switch 66 | } 67 | ~ foo = not foo 68 | -> not_a_label 69 | 70 | == switch 71 | { x: 72 | - 0: zero 73 | - 1: one 74 | - 2: two 75 | - else: lots -> END 76 | } 77 | ~ x++ 78 | -> switch -------------------------------------------------------------------------------- /narrator/libs/classic.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- classic 3 | -- 4 | -- Copyright (c) 2014, rxi 5 | -- 6 | -- This module is free software; you can redistribute it and/or modify it under 7 | -- the terms of the MIT license. See LICENSE for details. 8 | -- 9 | 10 | 11 | local Object = {} 12 | Object.__index = Object 13 | 14 | 15 | function Object:new() 16 | end 17 | 18 | 19 | function Object:extend() 20 | local cls = {} 21 | for k, v in pairs(self) do 22 | if k:find("__") == 1 then 23 | cls[k] = v 24 | end 25 | end 26 | cls.__index = cls 27 | cls.super = self 28 | setmetatable(cls, self) 29 | return cls 30 | end 31 | 32 | 33 | function Object:implement(...) 34 | for _, cls in pairs({...}) do 35 | for k, v in pairs(cls) do 36 | if self[k] == nil and type(v) == "function" then 37 | self[k] = v 38 | end 39 | end 40 | end 41 | end 42 | 43 | 44 | function Object:is(T) 45 | local mt = getmetatable(self) 46 | while mt do 47 | if mt == T then 48 | return true 49 | end 50 | mt = getmetatable(mt) 51 | end 52 | return false 53 | end 54 | 55 | 56 | function Object:__tostring() 57 | return "Object" 58 | end 59 | 60 | 61 | function Object:__call(...) 62 | local obj = setmetatable({}, self) 63 | obj:new(...) 64 | return obj 65 | end 66 | 67 | 68 | return Object 69 | -------------------------------------------------------------------------------- /test/units/lists-basic.ink: -------------------------------------------------------------------------------- 1 | LIST daysOfTheWeek = Monday, Tuesday, Wednesday, Thursday, Friday 2 | VAR today = Monday 3 | VAR tomorrow = Tuesday 4 | 5 | LIST heatedWaterStates = cold, boiling, recently_boiled 6 | VAR kettleState = cold 7 | VAR potState = cold 8 | 9 | LIST colours = red, green, blue, purple 10 | LIST moods = mad, happy, blue 11 | VAR status = colours.blue 12 | 13 | LIST volumeLevel = off, quiet, medium, loud, deafening 14 | VAR lecturersVolume = quiet 15 | VAR murmurersVolume = quiet 16 | 17 | LIST Numbers = one, two, three 18 | VAR cats = one 19 | 20 | ~ cats = Numbers(2) // score will be "two" 21 | We have { cats } cats. 22 | 23 | -> today 24 | == today 25 | 26 | Today is { today }. Tomorrow is { tomorrow }. 27 | 28 | ... 29 | -> kitchen 30 | === kitchen 31 | 32 | 33 | - Hm, kettle is { kettleState } and pot is { potState }. 34 | ~ kettleState = boiling 35 | - Now kettle is { kettleState } and pot is { potState }. 36 | 37 | ... 38 | -> status 39 | === status 40 | 41 | Binary values are { status == colours.blue } and { status == moods.blue } 42 | 43 | ... 44 | -> lecture 45 | === lecture 46 | 47 | { lecturersVolume < deafening: 48 | ~ lecturersVolume++ 49 | The lecturer's voice becomes {lecturersVolume}. 50 | The lecturer has {LIST_VALUE(deafening) - LIST_VALUE(lecturersVolume)} notches still available to him. 51 | 52 | { lecturersVolume > murmurersVolume: 53 | ~ murmurersVolume++ 54 | The murmuring gets louder. 55 | } 56 | -> lecture 57 | } -------------------------------------------------------------------------------- /test/runtime/tags.lua: -------------------------------------------------------------------------------- 1 | local narrator, describe, it, assert = ... 2 | 3 | local content = [[ 4 | # global tag 1 5 | # global tag 2 # global tag 3 6 | Root line -> knot 7 | === knot === 8 | # knot tag 9 | A knot line 1 # line 1 tag 10 | A knot line 2 # line 2 tag 11 | = stitch 12 | # stitch tag 13 | A stitch line # line 3 tag 14 | ]] 15 | 16 | local book = narrator.parse_content(content) 17 | local story = narrator.init_story(book) 18 | 19 | story:begin() 20 | 21 | local paragraphs = story:continue() 22 | 23 | it('Global tags.', function() 24 | local expected = { 'global tag 1', 'global tag 2', 'global tag 3' } 25 | assert.are.same(expected, story.global_tags) 26 | 27 | local global_tags = story:get_tags() 28 | assert.are.same(expected, global_tags) 29 | end) 30 | 31 | it('Knot tags.', function() 32 | local expected = { 'knot tag' } 33 | local knot_tags = story:get_tags('knot') 34 | assert.are.same(expected, knot_tags) 35 | end) 36 | 37 | it('Stitch tags.', function() 38 | local expected = { 'stitch tag' } 39 | local stitch_tags = story:get_tags('knot.stitch') 40 | assert.are.same(expected, stitch_tags) 41 | end) 42 | 43 | it('Paragraph tags.', function() 44 | assert.equal(#paragraphs, 2) 45 | 46 | local expected = { 'global tag 1', 'global tag 2', 'global tag 3', 'knot tag', 'line 1 tag' } 47 | assert.are.same(expected, paragraphs[1].tags) 48 | 49 | local expected = { 'line 2 tag' } 50 | assert.are.same(expected, paragraphs[2].tags) 51 | end) -------------------------------------------------------------------------------- /test/cases.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Test cases 3 | 4 | local runtime = { 5 | 'continue', 6 | 'observing', 7 | 'binding', 8 | 'set-get', 9 | 'visits', 10 | 'tags', 11 | 'jumping', 12 | 'save-load' 13 | } 14 | 15 | local units = { 16 | 'inclusions', 17 | 'comments', 18 | 'knots', 19 | 'stitches', 20 | 21 | 'text-line', 22 | 'text-lines', 23 | 'text-tags', 24 | 'text-glue', 25 | 26 | 'choices-basic', 27 | 'choices-tags', 28 | 'choices-conditional', 29 | 'choices-sticky', 30 | 'choices-fallback', 31 | 'choices-tunnel', 32 | 33 | 'labels-choices', 34 | 'labels-nested', 35 | 36 | 'branching', 37 | 'nesting', 38 | 'gather', 39 | 'loop', 40 | 'vars', 41 | 'constants', 42 | 'expressions', 43 | 'queries', 44 | 45 | 'conditions-inline', 46 | 'alts-inline', 47 | 'conditions-switch', 48 | 'alts-blocks', 49 | 50 | 'lists-basic', 51 | 'lists-operators', 52 | 'lists-queries', 53 | 54 | 'tunnels', 55 | 'escape', 56 | 'functions' 57 | } 58 | 59 | local stories = { 60 | -- No complex stories at the moment 61 | } 62 | 63 | local cases = { 64 | runtime = runtime, 65 | units = units, 66 | stories = stories 67 | } 68 | 69 | local folder_separator = package.config:sub(1, 1) 70 | for folder_name, folder_cases in pairs(cases) do 71 | local items_with_foldes = { } 72 | for _, case in ipairs(folder_cases) do 73 | table.insert(items_with_foldes, folder_name .. folder_separator .. case) 74 | end 75 | cases[folder_name] = items_with_foldes 76 | end 77 | 78 | return cases -------------------------------------------------------------------------------- /test/runtime/save-load.lua: -------------------------------------------------------------------------------- 1 | local narrator, describe, it, assert = ... 2 | 3 | local content = [[ 4 | VAR x = 1 5 | * (choice) [Hello] 6 | - (hello) Hello world! 7 | -> knot 8 | === knot 9 | = stitch 10 | ~ x = 2 11 | ~ temp y = 3 12 | A line 1 13 | A line 2 14 | A line 3 15 | * Just a road to hell -> END 16 | * The best road to hell -> END 17 | ]] 18 | 19 | local book = narrator.parse_content(content) 20 | 21 | local saved_state 22 | 23 | it('Saving', function() 24 | local story = narrator.init_story(book) 25 | story:begin() 26 | story:continue() 27 | story:choose(1) 28 | story:continue(2) 29 | 30 | saved_state = story:save_state() 31 | 32 | local expected_path = { knot = 'knot', stitch = 'stitch' } 33 | assert.are.same(saved_state.path, expected_path) 34 | assert.equal(saved_state.variables['x'], 2) 35 | assert.equal(saved_state.temp['y'], 3) 36 | assert.equal(saved_state.visits._._.hello, 1) 37 | assert.equal(#saved_state.output, 2) 38 | assert.equal(#saved_state.paragraphs, 2) 39 | assert.equal(#saved_state.choices, 2) 40 | end) 41 | 42 | it('Loading.', function() 43 | local story = narrator.init_story(book) 44 | story:begin() 45 | story:load_state(saved_state) 46 | 47 | local expected_path = { knot = 'knot', stitch = 'stitch' } 48 | assert.are.same(story.current_path, expected_path) 49 | assert.equal(story.variables['x'], 2) 50 | assert.equal(story.temp['y'], 3) 51 | assert.equal(story:get_visits('hello'), 1) 52 | assert.equal(#story.output, 2) 53 | 54 | local paragraphs = story:continue() 55 | local choices = story:get_choices() 56 | 57 | assert.equal(#paragraphs, 2) 58 | assert.equal(#choices, 2) 59 | end) -------------------------------------------------------------------------------- /bot.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Bot for story playing 3 | 4 | local bot = { } 5 | 6 | --- Play a story by bot 7 | -- @param story Story: a story instance 8 | -- @param instructor function: function that will be return the answer index 9 | -- @param params.print boolean: print a game log to console or not, false by default 10 | -- @return string: a log of the game 11 | function bot.play(story, instructor, params) 12 | local params = params or { print = false } 13 | 14 | local log = { } 15 | local step = 1 16 | 17 | local function output(text) 18 | if params.print then print(text) end 19 | table.insert(log, text) 20 | end 21 | 22 | story:begin() 23 | 24 | while story:can_continue() or story:can_choose() do 25 | local paragraphs = story:continue() 26 | for _, paragraph in ipairs(paragraphs or { }) do 27 | local text = paragraph.text or '' 28 | if paragraph.tags then 29 | local hashtag = #text > 0 and ' #' or '#' 30 | text = text .. hashtag .. table.concat(paragraph.tags, ' #') 31 | end 32 | output(text) 33 | end 34 | 35 | if not story:can_choose() then break end 36 | 37 | local choices = story:get_choices() 38 | local answer = instructor(choices, step) 39 | step = step + 1 40 | 41 | -- Check for a signal to emergency exit 42 | if answer == -1 then 43 | return nil 44 | end 45 | 46 | output('') 47 | for i, choice in ipairs(choices) do 48 | local prefix = (i == answer and '>' or i) .. ') ' 49 | local text = prefix .. choice.text 50 | if choice.tags then 51 | text = text .. ' #' .. table.concat(choice.tags, ' #') 52 | end 53 | output(text) 54 | end 55 | output('') 56 | 57 | story:choose(answer) 58 | end 59 | 60 | return table.concat(log, '\n') 61 | end 62 | 63 | return bot -------------------------------------------------------------------------------- /game.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- A simple command-line game example 3 | 4 | -- Dependencies 5 | local narrator = require('narrator.narrator') 6 | 7 | -- Parse a book from the Ink file and save as module 'stories.game.lua' 8 | local book = narrator.parse_file('stories.game', { save = true }) 9 | 10 | -- Init a story from the book 11 | local story = narrator.init_story(book) 12 | 13 | -- Start observing the Ink variable 'x' 14 | story:observe('x', function(x) print('The x did change! Now it\'s ' .. x) end) 15 | 16 | -- Bind local functions to call from ink as external functions 17 | story:bind('beep', function() print('Beep! 😃') end) 18 | story:bind('sum', function(x, y) return x + y end) 19 | 20 | -- Begin the story 21 | story:begin() 22 | 23 | print('--- Game started ---\n') 24 | 25 | while story:can_continue() do 26 | 27 | -- Get current paragraphs to output 28 | local paragraphs = story:continue() 29 | 30 | for _, paragraph in ipairs(paragraphs) do 31 | local text = paragraph.text 32 | 33 | -- You can handle tags as you like, but we attach them to text here. 34 | if paragraph.tags then 35 | text = text .. ' #' .. table.concat(paragraph.tags, ' #') 36 | end 37 | 38 | -- Output text to the player 39 | print(text) 40 | end 41 | 42 | -- If there is no choice, it seems the game is over 43 | if not story:can_choose() then break end 44 | print('') 45 | 46 | -- Get available choices and output them to the player 47 | local choices = story:get_choices() 48 | for i, choice in ipairs(choices) do 49 | print(i .. ') ' .. choice.text) 50 | end 51 | 52 | -- Read the choice from the player input 53 | local answer = tonumber(io.read()) or 0 54 | print('') 55 | 56 | -- Send an answer to the story to generate new paragraphs 57 | story:choose(answer) 58 | end 59 | 60 | print('\n--- Game over ---') -------------------------------------------------------------------------------- /test/runtime/visits.lua: -------------------------------------------------------------------------------- 1 | local narrator, describe, it, assert = ... 2 | 3 | local content = [[ 4 | A root text 5 | + Go to knot -> knot 6 | === knot 7 | + Go to stitch -> stitch 8 | = stitch 9 | + Go to label -> label 10 | - (label) Some text 11 | + (choice) Go to root -> _._ 12 | ]] 13 | 14 | local book = narrator.parse_content(content) 15 | local story = narrator.init_story(book) 16 | 17 | story:begin() 18 | 19 | local function visits() 20 | local visits = { 21 | root = story:get_visits(''), 22 | knot = story:get_visits('knot'), 23 | stitch = story:get_visits('knot.stitch'), 24 | label = story:get_visits('knot.stitch.label'), 25 | choice = story:get_visits('knot.stitch.choice') 26 | } 27 | 28 | return visits 29 | end 30 | 31 | local places = { 'root', 'knot', 'stitch', 'label' } 32 | 33 | for cycle = 1, 3 do 34 | describe('Visits with cycle ' .. cycle .. '.', function() 35 | story:continue() 36 | 37 | for place_index = 1, #places do 38 | local place = places[place_index] 39 | 40 | it('Visit the ' .. place .. '.', function() 41 | story:continue() 42 | 43 | local place = place 44 | local divert = story.choices[1].divert 45 | 46 | local expected_root = cycle 47 | local expected_knot = place_index > 1 and cycle or cycle - 1 48 | local expected_stitch = place_index > 2 and cycle or cycle - 1 49 | local expected_label = place_index > 3 and cycle or cycle - 1 50 | local expected_choice = place_index > 4 and cycle or cycle - 1 51 | 52 | local visits = visits() 53 | 54 | assert.equal(visits.root, expected_root) 55 | assert.equal(visits.knot, expected_knot) 56 | assert.equal(visits.stitch, expected_stitch) 57 | assert.equal(visits.label, expected_label) 58 | assert.equal(visits.choice, expected_choice) 59 | 60 | story:choose(1) 61 | end) 62 | end 63 | 64 | end) 65 | end -------------------------------------------------------------------------------- /test/units/lists-queries.ink: -------------------------------------------------------------------------------- 1 | LIST GuardsOnDuty = (Smith), (Jones), Carter, Braithwaite 2 | LIST CoreValues = strength, courage, compassion, greed, nepotism, delusions_of_godhood 3 | 4 | Normal: { GuardsOnDuty } 5 | ~ GuardsOnDuty = LIST_INVERT(GuardsOnDuty) 6 | Inverted: { GuardsOnDuty } 7 | 8 | Range: { LIST_RANGE(GuardsOnDuty, Jones, Carter) } 9 | 10 | ... 11 | VAR desiredValues = (strength, courage, compassion, nepotism ) 12 | VAR actualValues = ( greed, nepotism, delusions_of_godhood ) 13 | {desiredValues ^ actualValues: The new president has at least one desirable quality. A cold {desiredValues ^ actualValues}.} 14 | {LIST_COUNT(desiredValues ^ actualValues) == 1: Correction, the new president has only one desirable quality. {desiredValues ^ actualValues == nepotism: It's the scary one.}} 15 | 16 | ... 17 | LIST Characters = Alfred, Batman, Robin 18 | LIST Props = champagne_glass, newspaper 19 | 20 | VAR BallroomContents = (Alfred, Batman, newspaper) 21 | VAR HallwayContents = (Robin, champagne_glass) 22 | VAR roomState = (Robin, champagne_glass) 23 | 24 | -> room 25 | == room 26 | { roomState ? Alfred: Alfred is here, standing quietly in a corner. } { roomState ? Batman: Batman's presence dominates all. } { roomState ? Robin: Robin is all but forgotten. } 27 | <> { roomState ? champagne_glass: A champagne glass lies discarded on the floor. } { roomState ? newspaper: On one table, a headline blares out WHO IS THE BATMAN? AND *WHO* IS HIS BARELY-REMEMBERED ASSISTANT? } 28 | 29 | { roomState == BallroomContents : 30 | -> letters 31 | - else: 32 | ~ roomState = BallroomContents 33 | -> room 34 | } 35 | 36 | == letters 37 | LIST Letters = a,b,c 38 | LIST Numbers = one, two, three 39 | VAR mixedList = (a, three, c) 40 | 41 | ... 42 | {LIST_ALL(mixedList)} // a, one, b, two, c, three 43 | {LIST_COUNT(mixedList)} // 3 44 | {LIST_MIN(mixedList)} // a 45 | {LIST_MAX(mixedList)} // c 46 | {mixedList ? (a,b) } // 0 (false) 47 | {mixedList ^ LIST_ALL(a)} // a, c 48 | { mixedList >= (one, a) } // 1 (true) 49 | { mixedList > (three) } // 0 (false) 50 | { LIST_INVERT(mixedList) } // one, b, two -------------------------------------------------------------------------------- /test/units/lists-operators.ink: -------------------------------------------------------------------------------- 1 | LIST DoctorsInSurgery = Adams, Bernard, (Cartwright), Denver, Eamonn 2 | 3 | Cartwright == { DoctorsInSurgery } 4 | 5 | ~ DoctorsInSurgery = (Adams, Bernard) 6 | Adams, Bernard == { DoctorsInSurgery } 7 | 8 | ~ DoctorsInSurgery = () 9 | Empty == { DoctorsInSurgery } Empty 10 | 11 | ~ DoctorsInSurgery = DoctorsInSurgery + Adams 12 | Adams == { DoctorsInSurgery } 13 | 14 | ~ DoctorsInSurgery += Eamonn 15 | Adams, Eamonn == { DoctorsInSurgery } 16 | 17 | ~ DoctorsInSurgery -= Eamonn 18 | Adams == { DoctorsInSurgery } 19 | 20 | ~ DoctorsInSurgery += (Eamonn, Denver) 21 | Adams, Eamonn, Denver == { DoctorsInSurgery } 22 | 23 | ~ DoctorsInSurgery -= (Adams, Eamonn, Denver) 24 | Empty == { DoctorsInSurgery } Empty again! 25 | 26 | ... 27 | ~ DoctorsInSurgery = (Adams, Cartwright) 28 | {LIST_COUNT(DoctorsInSurgery)} doctors in surgery. 29 | First is {LIST_MIN(DoctorsInSurgery)}. 30 | Last is {LIST_MAX(DoctorsInSurgery)}. 31 | 32 | Random seed = 1 { SEED_RANDOM(1)} 33 | A random doctor: {LIST_RANDOM(DoctorsInSurgery)} 34 | 35 | Working time! { DoctorsInSurgery: The surgery is open today. | Everyone has gone home. } 36 | ~ DoctorsInSurgery = () 37 | It's to late. { DoctorsInSurgery: The surgery is open today. | Everyone has gone home. } 38 | 39 | ~ DoctorsInSurgery = (Adams, Bernard) 40 | { DoctorsInSurgery == (Adams, Bernard): 41 | Dr Adams and Dr Bernard are having a loud argument in one corner. 42 | } 43 | 44 | { DoctorsInSurgery ? (Adams, Bernard): 45 | Dr Adams and Dr Bernard are having a hushed argument in one corner. 46 | } 47 | 48 | ~ DoctorsInSurgery += Eamonn 49 | 50 | { DoctorsInSurgery != (Adams, Bernard): 51 | At least Adams and Bernard aren't arguing. 52 | } 53 | 54 | { DoctorsInSurgery has Eamonn: 55 | Dr Eamonn is polishing his glasses. 56 | } 57 | 58 | { DoctorsInSurgery !? (Adams, Bernard) : Yeap, Adams and Bernard are outside. | Nope, Adams and Bernard are here. } 59 | 60 | All the doctors ({LIST_COUNT(LIST_ALL(DoctorsInSurgery))}): { LIST_ALL(DoctorsInSurgery) } 61 | All the doctors again: { LIST_ALL(Adams) } 62 | 63 | VAR myList = () 64 | ~ myList = DoctorsInSurgery() 65 | ~ myList += Adams 66 | { myList } here! -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Lua.diagnostics.globals": [ 3 | "unpack", 4 | "loadstring", 5 | "describe", 6 | "it", 7 | "msg", 8 | "sound", 9 | "hash", 10 | "vmath", 11 | "gui", 12 | "socket", 13 | "sys", 14 | "render", 15 | "go", 16 | "factory", 17 | "resource", 18 | "pprint", 19 | "timer", 20 | "particlefx", 21 | "spine", 22 | "sprite", 23 | "json", 24 | "window", 25 | "physics" 26 | ], 27 | "Lua.diagnostics.disable": [ 28 | "trailing-space", 29 | "redefined-local", 30 | "deprecated", 31 | "lowercase-global" 32 | ], 33 | "Lua.completion.callSnippet": "Replace", 34 | "Lua.completion.keywordSnippet": "Replace", 35 | "Lua.completion.showWord": "Fallback", 36 | "Lua.completion.autoRequire": false, 37 | "[lua]": { 38 | "editor.defaultFormatter": "sumneko.lua" 39 | }, 40 | "glsllint.additionalStageAssociations": { 41 | ".fp": "frag", 42 | ".vp": "vert" 43 | }, 44 | "files.associations": { 45 | "*.project": "ini", 46 | "*.script": "lua", 47 | "*.gui_script": "lua", 48 | "*.render_script": "lua", 49 | "*.editor_script": "lua", 50 | "*.fp": "glsl", 51 | "*.vp": "glsl", 52 | "*.go": "textproto", 53 | "*.animationset": "textproto", 54 | "*.atlas": "textproto", 55 | "*.buffer": "json", 56 | "*.camera": "textproto", 57 | "*.collection": "textproto", 58 | "*.collectionfactory": "textproto", 59 | "*.collectionproxy": "textproto", 60 | "*.collisionobject": "textproto", 61 | "*.display_profiles": "textproto", 62 | "*.factory": "textproto", 63 | "*.gamepads": "textproto", 64 | "*.gui": "textproto", 65 | "*.input_binding": "textproto", 66 | "*.label": "textproto", 67 | "*.material": "textproto", 68 | "*.mesh": "textproto", 69 | "*.model": "textproto", 70 | "*.particlefx": "textproto", 71 | "*.render": "textproto", 72 | "*.sound": "textproto", 73 | "*.spinemodel": "textproto", 74 | "*.spinescene": "textproto", 75 | "*.sprite": "textproto", 76 | "*.texture_profiles": "textproto", 77 | "*.tilemap": "textproto", 78 | "*.tilesource": "textproto", 79 | "*.manifest": "textproto" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /example-defold/example.gui: -------------------------------------------------------------------------------- 1 | script: "/example-defold/example.gui_script" 2 | fonts { 3 | name: "example" 4 | font: "/example-defold/example.font" 5 | } 6 | background_color { 7 | x: 0.0 8 | y: 0.0 9 | z: 0.0 10 | w: 0.0 11 | } 12 | nodes { 13 | position { 14 | x: 320.0 15 | y: 340.0 16 | z: 0.0 17 | w: 1.0 18 | } 19 | rotation { 20 | x: 0.0 21 | y: 0.0 22 | z: 0.0 23 | w: 1.0 24 | } 25 | scale { 26 | x: 0.5 27 | y: 0.5 28 | z: 0.5 29 | w: 1.0 30 | } 31 | size { 32 | x: 800.0 33 | y: 0.0 34 | z: 0.0 35 | w: 1.0 36 | } 37 | color { 38 | x: 0.102 39 | y: 0.102 40 | z: 0.102 41 | w: 1.0 42 | } 43 | type: TYPE_TEXT 44 | blend_mode: BLEND_MODE_ALPHA 45 | text: "paragraph" 46 | font: "example" 47 | id: "paragraph" 48 | xanchor: XANCHOR_NONE 49 | yanchor: YANCHOR_NONE 50 | pivot: PIVOT_N 51 | outline { 52 | x: 1.0 53 | y: 1.0 54 | z: 1.0 55 | w: 1.0 56 | } 57 | shadow { 58 | x: 1.0 59 | y: 1.0 60 | z: 1.0 61 | w: 1.0 62 | } 63 | adjust_mode: ADJUST_MODE_FIT 64 | line_break: true 65 | layer: "" 66 | inherit_alpha: true 67 | alpha: 1.0 68 | outline_alpha: 1.0 69 | shadow_alpha: 1.0 70 | template_node_child: false 71 | text_leading: 1.0 72 | text_tracking: 0.0 73 | custom_type: 0 74 | enabled: false 75 | visible: true 76 | material: "" 77 | } 78 | nodes { 79 | position { 80 | x: 320.0 81 | y: 300.0 82 | z: 0.0 83 | w: 1.0 84 | } 85 | rotation { 86 | x: 0.0 87 | y: 0.0 88 | z: 0.0 89 | w: 1.0 90 | } 91 | scale { 92 | x: 0.5 93 | y: 0.5 94 | z: 0.5 95 | w: 1.0 96 | } 97 | size { 98 | x: 800.0 99 | y: 0.0 100 | z: 0.0 101 | w: 1.0 102 | } 103 | color { 104 | x: 0.6 105 | y: 0.2 106 | z: 0.0 107 | w: 1.0 108 | } 109 | type: TYPE_TEXT 110 | blend_mode: BLEND_MODE_ALPHA 111 | text: "choice" 112 | font: "example" 113 | id: "choice" 114 | xanchor: XANCHOR_NONE 115 | yanchor: YANCHOR_NONE 116 | pivot: PIVOT_N 117 | outline { 118 | x: 1.0 119 | y: 1.0 120 | z: 1.0 121 | w: 1.0 122 | } 123 | shadow { 124 | x: 1.0 125 | y: 1.0 126 | z: 1.0 127 | w: 1.0 128 | } 129 | adjust_mode: ADJUST_MODE_FIT 130 | line_break: true 131 | layer: "" 132 | inherit_alpha: true 133 | alpha: 1.0 134 | outline_alpha: 1.0 135 | shadow_alpha: 1.0 136 | template_node_child: false 137 | text_leading: 1.0 138 | text_tracking: 0.0 139 | custom_type: 0 140 | enabled: false 141 | visible: true 142 | material: "" 143 | } 144 | material: "/builtins/materials/gui.material" 145 | adjust_reference: ADJUST_REFERENCE_PARENT 146 | max_nodes: 512 147 | -------------------------------------------------------------------------------- /narrator/narrator.lua: -------------------------------------------------------------------------------- 1 | local lume = require('narrator.libs.lume') 2 | local enums = require('narrator.enums') 3 | local parser = require('narrator.parser') 4 | local Story = require('narrator.story') 5 | 6 | -- 7 | -- Local 8 | 9 | local folder_separator = package.config:sub(1, 1) 10 | 11 | ---Clear path from '.lua' and '.ink' extensions and replace '.' to '/' or '\' 12 | ---@param path string 13 | ---@return string normalized_path 14 | local function normalize_path(path) 15 | local path = path:gsub('.lua$', '') 16 | local path = path:gsub('.ink$', '') 17 | 18 | if path:match('%.') and not path:match(folder_separator) then 19 | path = path:gsub('%.', folder_separator) 20 | end 21 | 22 | return path 23 | end 24 | 25 | ---Parse an .ink file to the content string. 26 | ---@param path string 27 | ---@return string content 28 | local function read_ink_file(path) 29 | local path = normalize_path(path) .. '.ink' 30 | 31 | local file = io.open(path, 'r') 32 | assert(file, 'File doesn\'t exist: ' .. path) 33 | 34 | local content = file:read('*all') 35 | file:close() 36 | 37 | return content 38 | end 39 | 40 | ---Save a book to the lua module 41 | ---@param book Narrator.Book 42 | ---@param path string 43 | ---@return boolean success 44 | local function save_book(book, path) 45 | local path = normalize_path(path) .. '.lua' 46 | 47 | local data = lume.serialize(book) 48 | data = data:gsub('%[%d+%]=', '') 49 | data = data:gsub('[\'[%w_]+\']', function(match) return 50 | match:sub(3, #match - 2) 51 | end) 52 | 53 | local file = io.open(path, 'w') 54 | if file == nil then 55 | return false 56 | end 57 | 58 | file:write('return ' .. data) 59 | file:close() 60 | 61 | return true 62 | end 63 | 64 | ---Merge a chapter to the book 65 | ---@param book Narrator.Book 66 | ---@param chapter Narrator.Book 67 | ---@return Narrator.Book 68 | local function merge_chapter_to_book(book, chapter) 69 | -- Check a engine version compatibility 70 | if chapter.version.engine and chapter.version.engine ~= enums.engine_version then 71 | assert('Version ' .. chapter.version.engine .. ' of book isn\'t equal to the version ' .. enums.engine_version .. ' of Narrator.') 72 | end 73 | 74 | --Merge the root knot and it's stitch 75 | book.tree._._ = lume.concat(chapter.tree._._, book.tree._._) 76 | chapter.tree._._ = nil 77 | book.tree._ = lume.merge(chapter.tree._, book.tree._) 78 | chapter.tree._ = nil 79 | 80 | --Merge a chapter to the book 81 | book.tree = lume.merge(book.tree or { }, chapter.tree or { }) 82 | book.constants = lume.merge(book.constants or { }, chapter.constants or { }) 83 | book.lists = lume.merge(book.lists or { }, chapter.lists or { }) 84 | book.variables = lume.merge(book.variables or { }, chapter.variables or { }) 85 | book.params = lume.merge(book.params or { }, chapter.params or { }) 86 | 87 | return book 88 | end 89 | 90 | -- 91 | -- Public 92 | 93 | local narrator = { } 94 | 95 | ---Parse a book from an Ink file 96 | ---Use it during development, but prefer already parsed and stored books in production 97 | ---Requires `lpeg` and `io`. 98 | ---@param path string 99 | ---@param params Narrator.ParsingParams|nil 100 | ---@return Narrator.Book 101 | function narrator.parse_file(path, params) 102 | local params = params or { save = false } 103 | assert(parser, 'Can\'t parse anything without lpeg, sorry.') 104 | 105 | local content = read_ink_file(path) 106 | local book = parser.parse(content) 107 | 108 | for _, inclusion in ipairs(book.inclusions) do 109 | local folder_path = normalize_path(path):match('(.*' .. folder_separator .. ')') 110 | local inclusion_path = folder_path .. normalize_path(inclusion) .. '.ink' 111 | local chapter = narrator.parse_file(inclusion_path) 112 | 113 | merge_chapter_to_book(book, chapter) 114 | end 115 | 116 | if params.save then 117 | save_book(book, path) 118 | end 119 | 120 | return book 121 | end 122 | 123 | ---Parse a book from the ink content string 124 | ---Use it during development, but prefer already parsed and stored books in production 125 | ---Requires `lpeg` 126 | ---@param content string 127 | ---@param inclusions string[] 128 | ---@return Narrator.Book 129 | function narrator.parse_content(content, inclusions) 130 | local inclusions = inclusions or { } 131 | assert(parser, 'Can\'t parse anything without a parser.') 132 | 133 | local book = parser.parse(content) 134 | 135 | for _, inclusion in ipairs(inclusions) do 136 | local chapter = parser.parse(inclusion) 137 | merge_chapter_to_book(book, chapter) 138 | end 139 | 140 | return book 141 | end 142 | 143 | ---Init a story based on the book 144 | ---@param book Narrator.Book 145 | ---@return Narrator.Story 146 | function narrator.init_story(book) 147 | return Story(book) 148 | end 149 | 150 | return narrator -------------------------------------------------------------------------------- /example-defold/example.gui_script: -------------------------------------------------------------------------------- 1 | local narrator = require('narrator.narrator') 2 | 3 | ---@class Self 4 | ---@field paragraph_template node 5 | ---@field paragraph_initial_position vector3 6 | ---@field paragraph_nodes node[] 7 | ---@field choice_template node 8 | ---@field choice_initial_position vector3 9 | ---@field choice_nodes node[] 10 | ---@field book Narrator.Book 11 | ---@field story Narrator.Story 12 | 13 | ---@param node node 14 | ---@param text string 15 | ---@return integer height 16 | local function estimated_height(node, text) 17 | local font_name = gui.get_font(node) 18 | local font = gui.get_font_resource(font_name) 19 | 20 | local metrics = resource.get_text_metrics(font, text, { 21 | width = gui.get_size(node).x, 22 | line_break = true 23 | }) 24 | 25 | return metrics.height 26 | end 27 | 28 | ---@param self Self 29 | local function clear_output(self) 30 | for index = #self.paragraph_nodes, 1, -1 do 31 | local node = self.paragraph_nodes[index] 32 | table.remove(self.paragraph_nodes, index) 33 | 34 | gui.animate(node, 'color.w', 0, gui.EASING_LINEAR, 0.5, 0, function(_, node) 35 | gui.delete_node(node) 36 | end) 37 | end 38 | 39 | for index = #self.choice_nodes, 1, -1 do 40 | local node = self.choice_nodes[index] 41 | table.remove(self.choice_nodes, index) 42 | 43 | gui.animate(node, 'color.w', 0, gui.EASING_LINEAR, 0.5, 0, function(_, node) 44 | gui.delete_node(node) 45 | end) 46 | end 47 | end 48 | 49 | ---@param self Self 50 | local function display_output(self) 51 | clear_output(self) 52 | 53 | -- Pull all the paragraphs 54 | local paragraphs = self.story:continue() 55 | 56 | -- Get the available choices 57 | local choices = self.story:get_choices() 58 | 59 | local paragraph_position = vmath.vector3(self.paragraph_initial_position) 60 | local choice_position = vmath.vector3(self.choice_initial_position) 61 | 62 | for index = #paragraphs, 1, -1 do 63 | local paragraph = paragraphs[index] 64 | local node = gui.clone(self.paragraph_template) 65 | gui.set_text(node, paragraph.text) 66 | 67 | local height = estimated_height(node, paragraph.text) 68 | if index < #paragraphs then 69 | paragraph_position.y = paragraph_position.y + height / 2 + 16 70 | end 71 | 72 | gui.set_position(node, paragraph_position) 73 | gui.set_enabled(node, true) 74 | gui.set_alpha(node, 0) 75 | gui.animate(node, 'color.w', 1, go.EASING_LINEAR, 0.5, index / 2) 76 | 77 | table.insert(self.paragraph_nodes, node) 78 | end 79 | 80 | for index = 1, #choices do 81 | local choice = choices[index] 82 | local node = gui.clone(self.choice_template) 83 | gui.set_text(node, choice.text) 84 | 85 | local height = estimated_height(node, choice.text) 86 | if index > 1 then 87 | choice_position.y = choice_position.y - height / 2 - 16 88 | end 89 | 90 | gui.set_enabled(node, true) 91 | gui.set_position(node, choice_position) 92 | 93 | local size = gui.get_size(node) 94 | size.y = height 95 | gui.set_size(node, size) 96 | 97 | gui.set_alpha(node, 0) 98 | gui.animate(node, 'color.w', 1, go.EASING_LINEAR, 0.5, (#paragraphs + 1) / 2) 99 | 100 | table.insert(self.choice_nodes, node) 101 | end 102 | end 103 | 104 | ---@param self Self 105 | function init(self) 106 | msg.post('@render:', 'clear_color', { color = vmath.vector4(1, 1, 1, 1)}) 107 | msg.post('.', 'acquire_input_focus') 108 | 109 | self.paragraph_template = gui.get_node('paragraph') 110 | self.paragraph_initial_position = gui.get_position(self.paragraph_template) 111 | self.paragraph_nodes = {} 112 | 113 | self.choice_template = gui.get_node('choice') 114 | self.choice_initial_position = gui.get_position(self.choice_template) 115 | self.choice_nodes = {} 116 | 117 | -- Parse and save a book 118 | self.book = narrator.parse_file('example-defold.book', { save = true }) 119 | 120 | -- Or load and parse the Ink file from the custom resources 121 | -- local content = sys.load_resource('/example-defold/book.ink') 122 | -- self.book = narrator.parseBook(content) 123 | 124 | -- Or load a book from the saved lua module 125 | -- self.book = require('example-defold.book') 126 | 127 | -- Or parse a book from the string with Ink content 128 | -- self.book = narrator.parseBook('Hello world!') 129 | 130 | -- Init a story 131 | self.story = narrator.init_story(self.book) 132 | 133 | -- Begin the story 134 | self.story:begin() 135 | 136 | display_output(self) 137 | end 138 | 139 | ---@param self Self 140 | ---@param action_id hash|string 141 | ---@param action table 142 | function on_input(self, action_id, action) 143 | if action_id ~= hash 'touch' or not action.pressed then 144 | return 145 | end 146 | 147 | if not self.story:can_choose() then 148 | -- Begin a new story 149 | self.story = narrator.init_story(self.book) 150 | self.story:begin() 151 | 152 | display_output(self) 153 | 154 | return true 155 | end 156 | 157 | for index, node in ipairs(self.choice_nodes) do 158 | if gui.pick_node(node, action.x, action.y) then 159 | -- Make a choice 160 | self.story:choose(index) 161 | 162 | display_output(self) 163 | 164 | return true 165 | end 166 | end 167 | end -------------------------------------------------------------------------------- /test/run.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Testing with Busted library 3 | 4 | -- 5 | -- Dependencies 6 | 7 | local import = require 8 | import('busted.runner')() 9 | 10 | if os.getenv('LOCAL_LUA_DEBUGGER_VSCODE') == '1' then 11 | import('lldebugger').start() 12 | end 13 | 14 | local bot = require('bot') 15 | local narrator = require('narrator.narrator') 16 | local lume = require('narrator.libs.lume') 17 | 18 | -- 19 | -- Constants 20 | 21 | local folder_separator = package.config:sub(1, 1) 22 | local tests_folder = 'test' .. folder_separator 23 | 24 | --- Make path for a .lua file 25 | -- @param case string: an runtime test case 26 | -- @return string: a .lua path 27 | local function lua_path(case) 28 | return tests_folder .. case .. '.lua' 29 | end 30 | 31 | --- Make path for an .ink file 32 | -- @param case string: an Ink test case 33 | -- @return string: an .ink path 34 | local function ink_path(case) 35 | return tests_folder .. case .. '.ink' 36 | end 37 | 38 | --- Make path for a .txt file 39 | -- @param case string: an Ink test case 40 | -- @param answers table: a sequence of answers (numbers) 41 | -- @return string: a .txt path 42 | local function txt_path(case, answers) 43 | local path = tests_folder .. case 44 | if answers and #answers > 0 then 45 | path = path .. folder_separator .. table.concat(answers, '-') 46 | end 47 | path = path .. '.txt' 48 | return path 49 | end 50 | 51 | --- Get all possible sequences and logs of the case 52 | -- @param case string: an Ink test case 53 | -- @return table: an array of possible games { sequence, log } 54 | local function get_possible_results(case) 55 | local path = ink_path(case) 56 | local book = narrator.parse_file(path) 57 | 58 | local results = { } 59 | local sequences = { { } } 60 | local seq_index 61 | 62 | local function instructor(choices, step) 63 | local cur_seq = sequences[seq_index] 64 | local answer = cur_seq[step] 65 | 66 | if not answer then 67 | -- Transform a current sequence to branches for each available choice 68 | table.remove(sequences, seq_index) 69 | 70 | for index, _ in ipairs(choices) do 71 | local new_seq = lume.concat(cur_seq, { index }) 72 | table.insert(sequences, new_seq) 73 | end 74 | 75 | -- Set a stop signal for the bot 76 | answer = -1 77 | end 78 | 79 | return answer 80 | end 81 | 82 | while #sequences > 0 do 83 | -- Iterate sequences and play them 84 | for index = 1, #sequences do 85 | local sequence = sequences[index] 86 | seq_index = index 87 | 88 | -- Play the sequence 89 | local story = narrator.init_story(book) 90 | local log = bot.play(story, instructor) 91 | 92 | -- If the sequence was finished then save the result and mark it as finished 93 | if log then 94 | local result = { sequence = sequence, log = log } 95 | table.insert(results, result) 96 | sequences[index] = { is_finished = true } 97 | end 98 | end 99 | 100 | -- Remove finished sequences 101 | for index = #sequences, 1, -1 do 102 | local sequence = sequences[index] 103 | if sequence.is_finished then 104 | table.remove(sequences, index) 105 | end 106 | end 107 | end 108 | 109 | return results 110 | end 111 | 112 | --- Create possible results for an Ink test case and save them to txt files 113 | -- @param case string: an Ink test case 114 | -- @param override boolean: override a txt file if it already exists. 115 | local function create_txt_for_ink_case(case, override) 116 | local override = override ~= nil and override or false 117 | local results = get_possible_results(case) 118 | 119 | for _, result in ipairs(results) do 120 | local txt_path = txt_path(case, #results > 1 and result.sequence or nil) 121 | local file = io.open(txt_path, 'r') 122 | local is_file_exists = file ~= nil 123 | if is_file_exists then io.close(file) end 124 | 125 | if not is_file_exists or override then 126 | local folder_path = txt_path:match('(.*' .. folder_separator .. ')') 127 | local folder = io.open(folder_path, 'r') 128 | local is_folder_exists = folder ~= nil 129 | if is_folder_exists then 130 | io.close(folder) 131 | else 132 | os.execute('mkdir ' .. folder_path) 133 | end 134 | 135 | file = io.open(txt_path, 'w') 136 | assert(file, 'Has no access to the file at path \'' .. txt_path .. '\'.') 137 | 138 | file:write(result.log) 139 | file:close() 140 | end 141 | end 142 | end 143 | 144 | --- Create possible results for Ink test cases and save them to txt files 145 | -- @param cases table: an array of Ink test cases 146 | -- @param override bool: override the txt file if it already exists 147 | local function create_txt_for_ink_cases(cases, override) 148 | for _, case in ipairs(cases) do 149 | create_txt_for_ink_case(case, override) 150 | end 151 | end 152 | 153 | --- Test an Ink case 154 | -- @param case string: an Ink test case 155 | local function test_ink_case(case) 156 | describe('Test an Ink case \'' .. case .. '\'.', function() 157 | local path = ink_path(case) 158 | local book = narrator.parse_file(path) 159 | 160 | local results = get_possible_results(case) 161 | for _, result in ipairs(results) do 162 | describe('Sequence is [' .. table.concat(result.sequence, '-') .. '].', function() 163 | local txt_path = txt_path(case, #results > 1 and result.sequence or nil) 164 | local file = io.open(txt_path, 'r') 165 | 166 | it('Checking results.', function() 167 | assert.is_not_nil(file) 168 | 169 | local expected = file:read('*all') 170 | file:close() 171 | 172 | assert.are.same(expected, result.log) 173 | end) 174 | end) 175 | end 176 | end) 177 | end 178 | 179 | --- Test Ink cases 180 | -- @param cases table: an array of test cases 181 | local function test_ink_cases(cases) 182 | for _, case in ipairs(cases) do 183 | test_ink_case(case) 184 | end 185 | end 186 | 187 | --- Test a runtime case 188 | -- @param case string: a runtime test case 189 | local function test_lua_case(case) 190 | describe('Test a runtime case \'' .. case .. '\'.', function() 191 | local lua_path = lua_path(case) 192 | loadfile(lua_path)(narrator, describe, it, assert) 193 | end) 194 | end 195 | 196 | --- Test runtime cases 197 | -- @param cases table: an array of runtime test cases 198 | local function test_lua_cases(cases) 199 | for _, case in ipairs(cases) do 200 | test_lua_case(case) 201 | end 202 | end 203 | 204 | --- Override math.random functions to prevent different results beetween machines and pass test-cases. 205 | local function override_random() 206 | local test_seed 207 | local original_random = math.random 208 | 209 | math.randomseed = function(x) 210 | test_seed = x 211 | end 212 | 213 | math.random = function(x, y) 214 | if test_seed then 215 | local result = math.max(x, math.min(test_seed, y)) 216 | test_seed = nil 217 | return result 218 | else 219 | return original_random(x, y) 220 | end 221 | end 222 | end 223 | 224 | -- 225 | -- Main 226 | 227 | local case = nil 228 | local cases = require('test.cases') 229 | local override_case_results = false 230 | 231 | override_random() 232 | 233 | if override_case_results then 234 | if case then 235 | create_txt_for_ink_case(case, true) 236 | else 237 | create_txt_for_ink_cases(cases.units, true) 238 | create_txt_for_ink_cases(cases.stories, true) 239 | end 240 | end 241 | 242 | if case then 243 | if case:find('runtime/', 1, 8) then 244 | test_lua_case(case) 245 | else 246 | test_ink_case(case) 247 | end 248 | else 249 | test_lua_cases(cases.runtime) 250 | test_ink_cases(cases.units) 251 | test_ink_cases(cases.stories) 252 | end -------------------------------------------------------------------------------- /narrator/list/mt.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Dependencies 3 | 4 | local lume = require('narrator.libs.lume') 5 | 6 | -- 7 | -- Metatable 8 | 9 | local mt = { lists = { } } 10 | 11 | function mt.__tostring(self) 12 | local pool = { } 13 | 14 | local list_keys = { } 15 | for key, _ in pairs(self) do 16 | table.insert(list_keys, key) 17 | end 18 | table.sort(list_keys) 19 | 20 | for i = 1, #list_keys do 21 | local list_name = list_keys[i] 22 | local list_items = self[list_name] 23 | for index = 1, #mt.lists[list_name] do 24 | pool[index] = pool[index] or { } 25 | local item_name = mt.lists[list_name][index] 26 | if list_items[item_name] == true then 27 | table.insert(pool[index], 1, item_name) 28 | end 29 | end 30 | end 31 | 32 | local items = { } 33 | 34 | for _, titles in ipairs(pool) do 35 | for _, title in ipairs(titles) do 36 | table.insert(items, title) 37 | end 38 | end 39 | 40 | return table.concat(items, ', ') 41 | end 42 | 43 | -- 44 | -- Operators 45 | 46 | function mt.__add(lhs, rhs) -- + 47 | if type(rhs) == 'table' then 48 | return mt.__add_list(lhs, rhs) 49 | elseif type(rhs) == 'number' then 50 | return mt.__shift_by_number(lhs, rhs) 51 | else 52 | error('Attempt to sum the list with ' .. type(rhs)) 53 | end 54 | end 55 | 56 | function mt.__sub(lhs, rhs) -- - 57 | if type(rhs) == 'table' then 58 | return mt.__subList(lhs, rhs) 59 | elseif type(rhs) == 'number' then 60 | return mt.__shift_by_number(lhs, -rhs) 61 | else 62 | error('Attempt to sub the list with ' .. type(rhs)) 63 | end 64 | end 65 | 66 | function mt.__mod(lhs, rhs) -- % (contain) 67 | if type(rhs) ~= 'table' then 68 | error('Attempt to check content of the list for ' .. type(rhs)) 69 | end 70 | 71 | for list_name, list_items in pairs(rhs) do 72 | if lhs[list_name] == nil then return false end 73 | for item_name, item_value in pairs(list_items) do 74 | if (lhs[list_name][item_name] or false) ~= item_value then return false end 75 | end 76 | end 77 | 78 | return true 79 | end 80 | 81 | function mt.__pow(lhs, rhs) -- ^ (intersection) 82 | if type(rhs) ~= 'table' then 83 | error('Attempt to interselect the list with ' .. type(rhs)) 84 | end 85 | 86 | local intersection = { } 87 | 88 | for list_name, list_items in pairs(lhs) do 89 | for item_name, item_value in pairs(list_items) do 90 | local left = lhs[list_name][item_name] 91 | local right = (rhs[list_name] or { })[item_name] 92 | if left == true and right == true then 93 | intersection[list_name] = intersection[list_name] or { } 94 | intersection[list_name][item_name] = true 95 | end 96 | end 97 | end 98 | 99 | setmetatable(intersection, mt) 100 | return intersection 101 | end 102 | 103 | function mt.__len(self) -- # 104 | local len = 0 105 | 106 | for list_name, list_items in pairs(self) do 107 | for item_name, item_value in pairs(list_items) do 108 | if item_value == true then len = len + 1 end 109 | end 110 | end 111 | 112 | return len 113 | end 114 | 115 | function mt.__eq(lhs, rhs) -- == 116 | if type(rhs) ~= 'table' then 117 | error('Attempt to compare the list with ' .. type(rhs)) 118 | end 119 | 120 | local function keys_count(object) 121 | local count = 0 122 | for _, _ in pairs(object) do 123 | count = count + 1 124 | end 125 | return count 126 | end 127 | 128 | local left_lists_count = keys_count(lhs) 129 | local right_lists_count = keys_count(rhs) 130 | if left_lists_count ~= right_lists_count then 131 | return false 132 | end 133 | 134 | for list_name, left_items in pairs(lhs) do 135 | local right_items = rhs[list_name] 136 | if right_items == nil then 137 | return false 138 | end 139 | 140 | local left_items_count = keys_count(left_items) 141 | local right_items_count = keys_count(right_items) 142 | 143 | if left_items_count ~= right_items_count then 144 | return false 145 | end 146 | end 147 | 148 | return mt.__mod(lhs, rhs) 149 | end 150 | 151 | function mt.__lt(lhs, rhs) -- < 152 | if type(rhs) ~= 'table' then 153 | error('Attempt to compare the list with ' .. type(rhs)) 154 | end 155 | 156 | -- LEFT < RIGHT means "the smallest value in RIGHT is bigger than the largest values in LEFT" 157 | 158 | local minLeft = mt.min_value_of(lhs, true) 159 | local maxRight = mt.max_value_of(rhs, true) 160 | 161 | return minLeft < maxRight 162 | end 163 | 164 | function mt.__le(lhs, rhs) -- <= 165 | if type(rhs) ~= 'table' then 166 | error('Attempt to compare the list with ' .. type(rhs)) 167 | end 168 | 169 | -- LEFT => RIGHT means "the smallest value in RIGHT is at least the smallest value in LEFT, 170 | -- and the largest value in RIGHT is at least the largest value in LEFT". 171 | 172 | local minRight = mt.min_value_of(rhs, true) 173 | local minLeft = mt.min_value_of(lhs, true) 174 | local maxRight = mt.max_value_of(rhs, true) 175 | local maxLeft = mt.max_value_of(lhs, true) 176 | 177 | return minRight >= minLeft and maxRight >= maxLeft 178 | end 179 | 180 | -- 181 | -- Custom operators 182 | 183 | function mt.__add_list(lhs, rhs) 184 | local result = lume.clone(lhs) 185 | 186 | for list_name, list_items in pairs(rhs) do 187 | result[list_name] = result[list_name] or { } 188 | for item_name, item_value in pairs(list_items) do 189 | result[list_name][item_name] = item_value 190 | end 191 | end 192 | 193 | return result 194 | end 195 | 196 | function mt.__subList(lhs, rhs) 197 | local result = lume.clone(lhs) 198 | 199 | for list_name, list_items in pairs(rhs) do 200 | if lhs[list_name] ~= nil then 201 | for item_name, _ in pairs(list_items) do 202 | lhs[list_name][item_name] = nil 203 | end 204 | end 205 | end 206 | 207 | return mt.remove_empties_in_list(result) 208 | end 209 | 210 | function mt.__shift_by_number(list, number) 211 | local result = { } 212 | 213 | for list_name, list_items in pairs(list) do 214 | result[list_name] = { } 215 | for index, item_name in ipairs(mt.lists[list_name]) do 216 | if list_items[item_name] == true then 217 | local nextItem = mt.lists[list_name][index + number] 218 | if nextItem ~= nil then 219 | result[list_name][nextItem] = true 220 | end 221 | end 222 | end 223 | end 224 | 225 | return mt.remove_empties_in_list(result) 226 | end 227 | 228 | -- 229 | -- Helpers 230 | 231 | function mt.remove_empties_in_list(list) 232 | local result = lume.clone(list) 233 | 234 | for list_name, list_items in pairs(list) do 235 | if next(list_items) == nil then 236 | result[list_name] = nil 237 | end 238 | end 239 | 240 | return result 241 | end 242 | 243 | function mt.min_value_of(list, raw) 244 | local min_index = 0 245 | local min_value = { } 246 | 247 | local list_keys = { } 248 | for key, _ in pairs(list) do 249 | table.insert(list_keys, key) 250 | end 251 | table.sort(list_keys) 252 | 253 | for i = 1, #list_keys do 254 | local list_name = list_keys[i] 255 | local list_items = list[list_name] 256 | for item_name, item_value in pairs(list_items) do 257 | if item_value == true then 258 | local index = lume.find(mt.lists[list_name], item_name) 259 | if index and index < min_index or min_index == 0 then 260 | min_index = index 261 | min_value = { [list_name] = { [item_name] = true } } 262 | end 263 | end 264 | end 265 | end 266 | 267 | return raw and min_index or min_value 268 | end 269 | 270 | function mt.max_value_of(list, raw) 271 | local max_index = 0 272 | local max_value = { } 273 | 274 | local list_keys = { } 275 | for key, _ in pairs(list) do 276 | table.insert(list_keys, key) 277 | end 278 | table.sort(list_keys) 279 | 280 | for i = 1, #list_keys do 281 | local list_name = list_keys[i] 282 | local list_items = list[list_name] 283 | for item_name, item_value in pairs(list_items) do 284 | if item_value == true then 285 | local index = lume.find(mt.lists[list_name], item_name) 286 | if index and index > max_index or max_index == 0 then 287 | max_index = index 288 | max_value = { [list_name] = { [item_name] = true } } 289 | end 290 | end 291 | end 292 | end 293 | 294 | return raw and max_index or max_value 295 | end 296 | 297 | function mt.random_value_of(list) 298 | local items = { } 299 | 300 | local list_keys = { } 301 | for key, _ in pairs(list) do 302 | table.insert(list_keys, key) 303 | end 304 | table.sort(list_keys) 305 | 306 | for i = 1, #list_keys do 307 | local list_name = list_keys[i] 308 | local list_items = list[list_name] 309 | local items_keys = { } 310 | for key, _ in pairs(list_items) do 311 | table.insert(items_keys, key) 312 | end 313 | table.sort(items_keys) 314 | 315 | for i = 1, #items_keys do 316 | local item_name = items_keys[i] 317 | local item_value = list_items[item_name] 318 | if item_value == true then 319 | local result = { [list_name] = { [item_name] = true } } 320 | table.insert(items, result) 321 | end 322 | end 323 | end 324 | 325 | local random_index = math.random(1, #items) 326 | return items[random_index] 327 | end 328 | 329 | function mt.first_raw_value_of(list) 330 | local result = 0 331 | 332 | for list_name, list_items in pairs(list) do 333 | for item_name, item_value in pairs(list_items) do 334 | if item_value == true then 335 | local index = lume.find(mt.lists[list_name], item_name) 336 | if index then 337 | result = index 338 | break 339 | end 340 | end 341 | end 342 | end 343 | 344 | return result 345 | end 346 | 347 | function mt.posible_values_of(list) 348 | local result = { } 349 | 350 | for list_name, list_items in pairs(list) do 351 | local subList = { } 352 | for _, item_name in ipairs(mt.lists[list_name]) do 353 | subList[item_name] = true 354 | end 355 | result[list_name] = subList 356 | end 357 | 358 | return result 359 | end 360 | 361 | function mt.range_of(list, min, max) 362 | if type(min) ~= 'table' and type(min) ~= 'number' then 363 | error('Attempt to get a range with incorrect min value of type ' .. type(min)) 364 | end 365 | if type(max) ~= 'table' and type(max) ~= 'number' then 366 | error('Attempt to get a range with incorrect max value of type ' .. type(max)) 367 | end 368 | 369 | local result = { } 370 | local allList = mt.posible_values_of(list) 371 | local min_index = type(min) == 'number' and min or mt.first_raw_value_of(min) 372 | local max_index = type(max) == 'number' and max or mt.first_raw_value_of(max) 373 | 374 | for list_name, list_items in pairs(allList) do 375 | for item_name, item_value in pairs(list_items) do 376 | local index = lume.find(mt.lists[list_name], item_name) 377 | if index and index >= min_index and index <= max_index and list[list_name][item_name] == true then 378 | result[list_name] = result[list_name] or { } 379 | result[list_name][item_name] = true 380 | end 381 | end 382 | end 383 | 384 | return result 385 | end 386 | 387 | function mt.invert(list) 388 | local result = mt.posible_values_of(list) 389 | 390 | for list_name, list_items in pairs(list) do 391 | for item_name, item_value in pairs(list_items) do 392 | if item_value == true then 393 | result[list_name][item_name] = nil 394 | end 395 | end 396 | end 397 | 398 | return result 399 | end 400 | 401 | return mt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](https://user-images.githubusercontent.com/4752473/85455900-141f8f80-b5a7-11ea-8cd7-b441d662b361.png) 2 | 3 | # Narrator 4 | 5 | [![Release](https://img.shields.io/github/v/release/astrochili/narrator.svg?include_prereleases=&sort=semver&color=blue)](https://github.com/astrochili/narrator/releases) 6 | [![License](https://img.shields.io/badge/License-MIT-blue)](https://github.com/astrochili/narrator/blob/master/LICENSE) 7 | [![Website](https://img.shields.io/badge/website-gray.svg?&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSIgdmlld0JveD0iMCAwIDE4IDE2Ij48Y2lyY2xlIGN4PSIzLjY2IiBjeT0iMTQuNzUiIHI9IjEuMjUiIGZpbGw9InVybCgjYSkiLz48Y2lyY2xlIGN4PSI4LjY2IiBjeT0iMTQuNzUiIHI9IjEuMjUiIGZpbGw9InVybCgjYikiLz48Y2lyY2xlIGN4PSIxMy42NSIgY3k9IjE0Ljc1IiByPSIxLjI1IiBmaWxsPSJ1cmwoI2MpIi8+PHBhdGggZmlsbD0idXJsKCNkKSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNNy42MyAxLjQ4Yy41LS43IDEuNTUtLjcgMi4wNSAwbDYuMjIgOC44MWMuNTguODMtLjAxIDEuOTctMS4wMyAxLjk3SDIuNDRhMS4yNSAxLjI1IDAgMCAxLTEuMDItMS45N2w2LjIxLTguODFaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyLjQxIiB4Mj0iMi40MSIgeTE9IjEzLjUiIHkyPSIxNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNGRDhENDIiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGOTU0MUYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjcuNDEiIHgyPSI3LjQxIiB5MT0iMTMuNSIgeTI9IjE2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iI0ZEOEQ0MiIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI0Y5NTQxRiIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJjIiB4MT0iMTIuNCIgeDI9IjEyLjQiIHkxPSIxMy41IiB5Mj0iMTYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjRkQ4RDQyIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjRjk1NDFGIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImQiIHgxPSIuMDMiIHgyPSIuMDMiIHkxPSIuMDMiIHkyPSIxMi4yNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNGRkU2NUUiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRkM4MzAiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4=)](https://astronachos.com/) 8 | [![Mastodon](https://img.shields.io/badge/mastodon-gray?&logo=mastodon)](https://mastodon.gamedev.place/@astronachos) 9 | [![Twitter](https://img.shields.io/badge/twitter-gray?&logo=twitter)](https://twitter.com/astronachos) 10 | [![Telegram](https://img.shields.io/badge/telegram-gray?&logo=telegram)](https://t.me/astronachos) 11 | [![Buy me a coffee](https://img.shields.io/badge/buy_me_a_coffee-gray?&logo=buy%20me%20a%20coffee)](https://buymeacoffee.com/astrochili) 12 | 13 | ## Overview 14 | 15 | The [Ink](https://www.inklestudios.com/ink/) language parser and runtime implementation in Lua. 16 | 17 | Ink is a powerful narrative scripting language. You can find more information about how to write Ink scripts [here](https://github.com/inkle/ink/blob/master/Documentation/WritingWithInk.md). There is also [Inky](https://github.com/inkle/inky) editor with useful features to test and debug Ink scripts. 18 | 19 | Narrator allows to convert raw Ink scripts to the book (a lua table) and play it as story. 20 | 21 | - 📖 A book is a passive model on the shelf like a game level. 22 | - ✨ A story is a runtime state of the book reading like a game process. 23 | 24 | ## Quick example 25 | 26 | ```lua 27 | local narrator = require('narrator.narrator') 28 | 29 | -- Parse a book from the Ink file. 30 | local book = narrator.parse_file('stories.game') 31 | 32 | -- Init a story from the book 33 | local story = narrator.init_story(book) 34 | 35 | -- Begin the story 36 | story:begin() 37 | 38 | while story:can_continue() do 39 | 40 | -- Get current paragraphs to output 41 | local paragraphs = story:continue() 42 | 43 | for _, paragraph in ipairs(paragraphs) do 44 | local text = paragraph.text 45 | 46 | -- You can handle tags as you like, but we attach them to text here. 47 | if paragraph.tags then 48 | text = text .. ' #' .. table.concat(paragraph.tags, ' #') 49 | end 50 | 51 | -- Output text to the player 52 | print(text) 53 | end 54 | 55 | -- If there is no choice it seems like the game is over 56 | if not story:can_choose() then break end 57 | 58 | -- Get available choices and output them to the player 59 | local choices = story:get_choices() 60 | for i, choice in ipairs(choices) do 61 | print(i .. ') ' .. choice.text) 62 | end 63 | 64 | -- Read the choice from the player input 65 | local answer = tonumber(io.read()) 66 | 67 | -- Send answer to the story to generate new paragraphs 68 | story:choose(answer) 69 | end 70 | ``` 71 | 72 | ## Alternatives 73 | 74 | - [defold-ink](https://github.com/abadonna/defold-ink) — The Ink language runtime implementation in Lua based on parsing compiled JSON files. 75 | 76 | ## Showcase 77 | 78 | - [Cat's Day](https://astronachos.com/catsday/) — A short card game about one furry. 79 | - [Rare Pets](https://jetpackcollective.games/rarepets/) — A merge game for mobile about pets that become what they eat. 80 | - [Sensual Hunting](https://store.steampowered.com/app/1967470/Sensual_Haunting/) (NSFW) — An adult only game where all the navigation and dialogs made with this library. 81 | - [The Secret Laboratory](https://astrochili.itch.io/the-secret-laboratory) — A short card game about the labaratory director. 82 | 83 | ## Features 84 | 85 | ### Supported 86 | 87 | - [x] Comments: singleline, multiline, todo's 88 | - [x] Tags: global tags, knot tags, stitch tags, paragraph tags 89 | - [x] Paths and sections: inclusions, knots, stitches, labels 90 | - [x] Choices: suppressing and mixing, labels, conditions, sticky and fallback choices, tags 91 | - [x] Branching: diversions, glues, gathers, nesting 92 | - [x] Tunnels 93 | - [x] Alternatives: sequences, cycles, once-only, shuffles, empty steps, nesting 94 | - [x] Multiline alternatives: all the same + shuffle options 95 | - [x] Conditions: logical operations, string queries, if and else statements, nesting 96 | - [x] Multiline conditions: all the same + elseif statements, switches, nesting 97 | - [x] Variables: assignments, constants, global variables, temporary variables, visits, lists 98 | - [x] Lists: logical operations, multivalued lists, multi-list lists, all the queries, work with numbers 99 | - [x] Game queries: all the queries without `TURNS()` and `TURNS_SINCE()` 100 | - [x] State: saving and loading 101 | - [x] Integration: external functions, variables observing, jumping 102 | - [x] Migration: the ability to implement the migration of player's saves after the book update 103 | - [x] Internal functions 104 | 105 | ### Unsupported 106 | 107 | - [ ] [Threads](https://github.com/astrochili/narrator/issues/22) 108 | - [ ] [Divert target as variable type](https://github.com/astrochili/narrator/issues/23) 109 | - [ ] [Assigning string evaluations to variables](https://github.com/astrochili/narrator/issues/24) 110 | - [ ] [Multiple parallel flows](https://github.com/astrochili/narrator/issues/25) 111 | 112 | Also there is a list of [known limitations](https://github.com/astrochili/narrator/labels/known%20limitation) on the issues page. 113 | 114 | ## Installation 115 | 116 | ### Common case (Löve, pure Lua, etc.) 117 | 118 | Download the latest [release archive](https://github.com/astrochili/narrator/releases) and require the `narrator` module. 119 | 120 | ```lua 121 | local narrator = require('narrator.narrator') 122 | ``` 123 | 124 | Narrator requires [lpeg](http://www.inf.puc-rio.br/~roberto/lpeg/) as dependency to parse Ink content. You can install it with [luarocks](https://luarocks.org/). 125 | 126 | ```shell 127 | $ luarocks install lpeg 128 | ``` 129 | 130 | In fact, you don't need `lpeg` in the release, but you need it locally to parse Ink content and generate lua versions of books to play in your game. Use parsing in development only, prefer already parsed and stored books in production. 131 | 132 | ### Defold 133 | 134 | Add links to the zip-archives of the latest versions of [narrator](https://github.com/astrochili/narrator/releases) and [defold-lpeg](https://github.com/astrochili/defold-lpeg/releases) to your Defold project as [dependencies](http://www.defold.com/manuals/libraries/). 135 | 136 | ``` 137 | https://github.com/astrochili/narrator/archive/master.zip 138 | https://github.com/astrochili/defold-lpeg/archive/master.zip 139 | ``` 140 | 141 | Then you can require the `narrator` module. 142 | 143 | ```lua 144 | local narrator = require('narrator.narrator') 145 | ``` 146 | 147 | ## Documentation 148 | 149 | ### narrator.parse_file(path, params) 150 | 151 | Parses the Ink file at path with all the inclusions and returns a book instance. Path notations `'stories/game.ink'`, `'stories/game'` and `'stories.game'` are valid. 152 | 153 | You can save a parsed book to the lua file with the same path by passing `{ save = true }` as `params` table. By default, the `params` table is `{ save = false }`. 154 | 155 | ```lua 156 | -- Parse a Ink file at path 'stories/game.ink' 157 | local book = narrator.parse_file('stories.game') 158 | 159 | -- Parse a Ink file at path 'stories/game.ink' 160 | -- and save the book at path 'stories/game.lua' 161 | local book = narrator.parse_file('stories.game', { save = true }) 162 | ``` 163 | Reading and saving files required `io` so if you can't work with files by this way use `narrator.parse_content()`. 164 | 165 | ### narrator.parse_content(content, inclusions) 166 | 167 | Parses the string with Ink content and returns a book instance. The `inclusions` param is optional and can be used to pass an array of strings with Ink content of inclusions. 168 | 169 | ```lua 170 | local content = 'Content of a root Ink file' 171 | local inclusions = { 172 | 'Content of an included Ink file', 173 | 'Content of another included Ink file' 174 | } 175 | 176 | -- Parse a string with Ink content 177 | local book = narrator.parse_content(content) 178 | 179 | -- Parse a string with Ink content and inclusions 180 | local book = narrator.parse_content(content, inclusions) 181 | ``` 182 | 183 | Content parsing is useful when you should manage files by your engine environment and don't want to use `io` module. For example, in Defold, you may want to load ink files as custom resources with [sys.load_resource()](https://defold.com/ref/sys/#sys.load_resource:filename). 184 | 185 | ### narrator.init_story(book) 186 | 187 | Inits a story instance from the book. This is aclual to use in production. For example, just load a book with `require()` and pass it to this function. 188 | 189 | ```lua 190 | -- Require a parsed and saved before book 191 | local book = require('stories.game') 192 | 193 | -- Init a story instance 194 | local story = narrator.init_story(book) 195 | ``` 196 | 197 | ### story:begin() 198 | 199 | Begins the story. Generates the first chunk of paragraphs and choices. 200 | 201 | ### story:can_continue() 202 | 203 | Returns a boolean, does the story have paragraphs to output or not. 204 | 205 | ```lua 206 | while story:can_continue() do 207 | -- Get paragraphs? 208 | end 209 | ``` 210 | 211 | ### story:continue(steps) 212 | 213 | Get the next paragraphs. You can specify the number of paragraphs that you want to pull by the `steps` param. 214 | - Pass nothing if you want to get all the currently available paragraphs. `0` also works. 215 | - Pass `1` if you want to get one next paragraph without wrapping to array. 216 | 217 | A paragraph is a table like `{ text = 'Hello.', tags = { 'tag1', 'tag2' } }`. Most of the paragraphs do not have tags so `tags` can be `nil`. 218 | 219 | 220 | ```lua 221 | -- Get all the currently available paragraphs 222 | local paragraphs = story:continue() 223 | 224 | -- Get one next paragraph 225 | local paragraph = story:continue(1) 226 | ``` 227 | 228 | ### story:can_choose() 229 | 230 | Returns a boolean, does the story have choices to output or not. Also returns `false` if there are available paragraphs to continue. 231 | 232 | ```lua 233 | if story:can_choose() do 234 | -- Get choices? 235 | end 236 | ``` 237 | 238 | ### story:get_choices() 239 | 240 | Returns an array of available choices. Returns an empty array if there are available paragraphs to continue. 241 | 242 | A choice is a table like `{ text = 'Bye.', tags = { 'tag1', 'tag2' } }`. Most of the choices do not have tags so `tags` can be `nil`. 243 | 244 | Choice tags are not an official feature of Ink, but it's a Narrator feature. These tags also will appear in the answer paragraph as it works in Ink by default. But if you have a completely eaten choice like `'[Answer] #tag'` you will receive tags only in the choice. 245 | 246 | ```lua 247 | -- Get available choices and output them to the player 248 | local choices = story:get_choices() 249 | for i, choice in ipairs(choices) do 250 | print(i .. ') ' .. choice.text) 251 | end 252 | ``` 253 | 254 | ### story:choose(index) 255 | 256 | Make a choice to continue the story. Pass the `index` of the choice that you was received with `get_choices()` before. Will do nothing if `can_continue()` returns `false`. 257 | 258 | ```lua 259 | -- Get the answer from the player in the terminal 260 | answer = tonumber(io.read()) 261 | 262 | -- Send the answer to the story to generate new paragraphs 263 | story:choose(answer) 264 | 265 | -- Get the new paragraphs 266 | local new_paragraphs = story:continue() 267 | ``` 268 | 269 | ### story:jump_to(path_string) 270 | 271 | Jumps to the path. The `path_string` param is a string like `'knot.stitch.label'`. 272 | 273 | ```lua 274 | -- Jump to the maze stitch in the adventure knot 275 | story:jump_to('adventure.maze') 276 | 277 | -- Get the maze paragraphs 278 | local maze_paragraphs = story:continue() 279 | ``` 280 | 281 | ### story:get_visits(path_string) 282 | 283 | Returns the number of visits to the path. The `path_string` param is a string like `'knot.stitch.label'`. 284 | 285 | ```lua 286 | -- Get the number of visits to the maze's red room 287 | local red_room_visits = story:get_visits('adventure.maze.red_room') 288 | 289 | -- Get the number of adventures visited. 290 | local adventure_visits = story:get_visits('adventure') 291 | ``` 292 | 293 | ### story:get_tags(path_string) 294 | 295 | Returns tags for the path. The `path_string` param is a string like `'knot.stitch'`. This function is useful when you want to get tags before continue the story and pull paragraphs. Read more about it [here](https://github.com/inkle/ink/blob/master/Documentation/RunningYourInk.md#knot-tags). 296 | 297 | ```lua 298 | -- Get tags for the path 'adventure.maze' 299 | local mazeTags = story:get_tags('adventure.maze') 300 | ``` 301 | 302 | ### story:save_state() 303 | 304 | Raturns a table with the story state that can be saved and restored later. Use it to save the game. 305 | 306 | ```lua 307 | -- Get the story's state 308 | local state = story:save_state() 309 | 310 | -- Save the state to your local storage 311 | manager.save(state) 312 | ``` 313 | 314 | ### story:load_state(state) 315 | 316 | Restores a story's state from the saved before state. Use it to load the game. 317 | 318 | ```lua 319 | -- Load the state from your local storage 320 | local state = manager.load() 321 | 322 | -- Restore the story's state 323 | story:load_state(state) 324 | 325 | ``` 326 | 327 | ### story:observe(variable, observer) 328 | 329 | Assigns an observer function to the variable's changes. 330 | 331 | ```lua 332 | local function x_did_change(x) 333 | print('The x did change! Now it\'s ' .. x) 334 | end 335 | 336 | -- Start observing the variable 'x' 337 | story:observe('x', x_did_change) 338 | ``` 339 | 340 | ### story:bind(func_name, handler) 341 | 342 | Binds a function to external calling from the Ink. The function can returns the value or not. 343 | 344 | ```lua 345 | local function beep() 346 | print('Beep! 😃') 347 | end 348 | 349 | local function sum(x, y) 350 | return x + y 351 | end 352 | 353 | -- Bind the function without params and returned value 354 | story:bind('beep', beep) 355 | 356 | -- Bind the function with params and returned value 357 | story:bind('sum', sum) 358 | ``` 359 | 360 | ### story.global_tags 361 | 362 | An array with book's global tags. Tags are strings of course. 363 | 364 | ```lua 365 | -- Get the global tags 366 | local global_tags = story.global_tags 367 | 368 | -- A hacky way to get the same global tags 369 | local global_tags = story:get_tags() 370 | ``` 371 | 372 | ### story.constants 373 | 374 | A table with book's constants. Just read them, constants changing is not a good idea. 375 | 376 | ```lua 377 | -- Get the theme value from the Ink constants 378 | local theme = story.constants['theme'] 379 | ``` 380 | 381 | ### story.variables 382 | 383 | A table with story's variables. You can read or change them by this way. 384 | 385 | ```lua 386 | -- Get the mood variable value 387 | local mood = story.variables['mood'] 388 | 389 | -- Set the mood variable value 390 | story.variables['mood'] = 'sunny' 391 | ``` 392 | 393 | ### story.migrate 394 | 395 | A function that you can specify for migration from old to new versions of your books. This is useful, for example, when you don't want to corrupt player's save after the game update. 396 | 397 | This is the place where you can rename or change variables, visits, update the current path, etc. The default implementation returns the same state without any migration. 398 | 399 | ```lua 400 | -- Default implementation 401 | function(state, old_version, new_version) return state end 402 | ``` 403 | 404 | The `old_version` is the version of the saved state, the `new_version` is the version of the book. You can specify the verson of the book with the constant `'version'` in the Ink content, otherwise it's equal to `0`. 405 | 406 | ```lua 407 | -- A migration function example 408 | local function migrate(state, old_version, new_version) 409 | 410 | -- Check the need for migration 411 | if new_version == old_version then 412 | return state 413 | end 414 | 415 | -- Migration for the second version of the book 416 | if new_version == 2 then 417 | 418 | -- Get the old value 419 | local old_mood = state.variables['mood'] 420 | 421 | -- If it exists then migrate ... 422 | if old_mood then 423 | -- ... migrate the old number value to the new string value 424 | state.variables['mood'] = old_mood < 50 and 'sadly' or 'sunny' 425 | end 426 | end 427 | 428 | return state 429 | end 430 | 431 | -- Assign the migration function before loading a saved game 432 | story.migrate = migrate 433 | 434 | -- Load the game 435 | story:load_state(saved_state) 436 | ``` 437 | 438 | ## Contribution 439 | 440 | ### Development 441 | 442 | There are some useful extensions and configs for [VSCode](https://code.visualstudio.com/) that I use in development of Narrator. 443 | 444 | - [Local Lua Debugger](https://github.com/tomblind/local-lua-debugger-vscode) by [tomblind](https://github.com/tomblind/). 445 | - [Lua Language Server](https://github.com/sumneko/lua-language-server) by [sunmeko](https://github.com/sumneko). 446 | - A task named `Busted` runs tests with `tests/run.lua`. 447 | - A lunch configuration named `Busted` runs the debugger with `tests/run.lua`. 448 | - A lunch configuration named `Debug` runs the debugger with `debug.lua`. 449 | 450 | ### Testing 451 | 452 | To run tests you need to install [busted](https://github.com/Olivine-Labs/busted). 453 | 454 | ```shell 455 | $ luarocks install busted 456 | ``` 457 | 458 | Don't forget also to install `lpeg` as described in [Common case](#common-case-löve-pure-lua-etc) installation section. 459 | 460 | After that you can run tests from the terminal: 461 | ```shell 462 | $ busted test/run.lua 463 | ``` 464 | 465 | ## Third Party Libraries 466 | 467 | - [LPeg](http://www.inf.puc-rio.br/~roberto/lpeg/) by [Roberto Ierusalimschy](http://www.inf.puc-rio.br/~roberto/) (MIT Licence). 468 | - [classic](https://github.com/rxi/classic) by [rxi](https://github.com/rxi) (MIT Licence). 469 | - [lume](https://github.com/rxi/lume) by [rxi](https://github.com/rxi) (MIT Licence). 470 | -------------------------------------------------------------------------------- /narrator/libs/lume.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- lume 3 | -- 4 | -- Copyright (c) 2020 rxi 5 | -- 6 | -- Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | -- this software and associated documentation files (the "Software"), to deal in 8 | -- the Software without restriction, including without limitation the rights to 9 | -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 10 | -- of the Software, and to permit persons to whom the Software is furnished to do 11 | -- so, subject to the following conditions: 12 | -- 13 | -- The above copyright notice and this permission notice shall be included in all 14 | -- copies or substantial portions of the Software. 15 | -- 16 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | -- SOFTWARE. 23 | -- 24 | 25 | local lume = { _version = "2.3.0" } 26 | 27 | local pairs, ipairs = pairs, ipairs 28 | local type, assert, unpack = type, assert, unpack or table.unpack 29 | local tostring, tonumber = tostring, tonumber 30 | local math_floor = math.floor 31 | local math_ceil = math.ceil 32 | local math_atan2 = math.atan2 or math.atan 33 | local math_sqrt = math.sqrt 34 | local math_abs = math.abs 35 | 36 | local noop = function() 37 | end 38 | 39 | local identity = function(x) 40 | return x 41 | end 42 | 43 | local patternescape = function(str) 44 | return str:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1") 45 | end 46 | 47 | local absindex = function(len, i) 48 | return i < 0 and (len + i + 1) or i 49 | end 50 | 51 | local iscallable = function(x) 52 | if type(x) == "function" then return true end 53 | local mt = getmetatable(x) 54 | return mt and mt.__call ~= nil 55 | end 56 | 57 | local getiter = function(x) 58 | if lume.isarray(x) then 59 | return ipairs 60 | elseif type(x) == "table" then 61 | return pairs 62 | end 63 | error("expected table", 3) 64 | end 65 | 66 | local iteratee = function(x) 67 | if x == nil then return identity end 68 | if iscallable(x) then return x end 69 | if type(x) == "table" then 70 | return function(z) 71 | for k, v in pairs(x) do 72 | if z[k] ~= v then return false end 73 | end 74 | return true 75 | end 76 | end 77 | return function(z) return z[x] end 78 | end 79 | 80 | 81 | 82 | function lume.clamp(x, min, max) 83 | return x < min and min or (x > max and max or x) 84 | end 85 | 86 | 87 | function lume.round(x, increment) 88 | if increment then return lume.round(x / increment) * increment end 89 | return x >= 0 and math_floor(x + .5) or math_ceil(x - .5) 90 | end 91 | 92 | 93 | function lume.sign(x) 94 | return x < 0 and -1 or 1 95 | end 96 | 97 | 98 | function lume.lerp(a, b, amount) 99 | return a + (b - a) * lume.clamp(amount, 0, 1) 100 | end 101 | 102 | 103 | function lume.smooth(a, b, amount) 104 | local t = lume.clamp(amount, 0, 1) 105 | local m = t * t * (3 - 2 * t) 106 | return a + (b - a) * m 107 | end 108 | 109 | 110 | function lume.pingpong(x) 111 | return 1 - math_abs(1 - x % 2) 112 | end 113 | 114 | 115 | function lume.distance(x1, y1, x2, y2, squared) 116 | local dx = x1 - x2 117 | local dy = y1 - y2 118 | local s = dx * dx + dy * dy 119 | return squared and s or math_sqrt(s) 120 | end 121 | 122 | 123 | function lume.angle(x1, y1, x2, y2) 124 | return math_atan2(y2 - y1, x2 - x1) 125 | end 126 | 127 | 128 | function lume.vector(angle, magnitude) 129 | return math.cos(angle) * magnitude, math.sin(angle) * magnitude 130 | end 131 | 132 | 133 | function lume.random(a, b) 134 | if not a then a, b = 0, 1 end 135 | if not b then b = 0 end 136 | return a + math.random() * (b - a) 137 | end 138 | 139 | 140 | function lume.randomchoice(t) 141 | return t[math.random(#t)] 142 | end 143 | 144 | 145 | function lume.weightedchoice(t) 146 | local sum = 0 147 | for _, v in pairs(t) do 148 | assert(v >= 0, "weight value less than zero") 149 | sum = sum + v 150 | end 151 | assert(sum ~= 0, "all weights are zero") 152 | local rnd = lume.random(sum) 153 | for k, v in pairs(t) do 154 | if rnd < v then return k end 155 | rnd = rnd - v 156 | end 157 | end 158 | 159 | 160 | function lume.isarray(x) 161 | return type(x) == "table" and x[1] ~= nil 162 | end 163 | 164 | 165 | function lume.push(t, ...) 166 | local n = select("#", ...) 167 | for i = 1, n do 168 | t[#t + 1] = select(i, ...) 169 | end 170 | return ... 171 | end 172 | 173 | 174 | function lume.remove(t, x) 175 | local iter = getiter(t) 176 | for i, v in iter(t) do 177 | if v == x then 178 | if lume.isarray(t) then 179 | table.remove(t, i) 180 | break 181 | else 182 | t[i] = nil 183 | break 184 | end 185 | end 186 | end 187 | return x 188 | end 189 | 190 | 191 | function lume.clear(t) 192 | local iter = getiter(t) 193 | for k in iter(t) do 194 | t[k] = nil 195 | end 196 | return t 197 | end 198 | 199 | 200 | function lume.extend(t, ...) 201 | for i = 1, select("#", ...) do 202 | local x = select(i, ...) 203 | if x then 204 | for k, v in pairs(x) do 205 | t[k] = v 206 | end 207 | end 208 | end 209 | return t 210 | end 211 | 212 | 213 | function lume.shuffle(t) 214 | local rtn = {} 215 | for i = 1, #t do 216 | local r = math.random(i) 217 | if r ~= i then 218 | rtn[i] = rtn[r] 219 | end 220 | rtn[r] = t[i] 221 | end 222 | return rtn 223 | end 224 | 225 | 226 | function lume.sort(t, comp) 227 | local rtn = lume.clone(t) 228 | if comp then 229 | if type(comp) == "string" then 230 | table.sort(rtn, function(a, b) return a[comp] < b[comp] end) 231 | else 232 | table.sort(rtn, comp) 233 | end 234 | else 235 | table.sort(rtn) 236 | end 237 | return rtn 238 | end 239 | 240 | 241 | function lume.array(...) 242 | local t = {} 243 | for x in ... do t[#t + 1] = x end 244 | return t 245 | end 246 | 247 | 248 | function lume.each(t, fn, ...) 249 | local iter = getiter(t) 250 | if type(fn) == "string" then 251 | for _, v in iter(t) do v[fn](v, ...) end 252 | else 253 | for _, v in iter(t) do fn(v, ...) end 254 | end 255 | return t 256 | end 257 | 258 | 259 | function lume.map(t, fn) 260 | fn = iteratee(fn) 261 | local iter = getiter(t) 262 | local rtn = {} 263 | for k, v in iter(t) do rtn[k] = fn(v) end 264 | return rtn 265 | end 266 | 267 | 268 | function lume.all(t, fn) 269 | fn = iteratee(fn) 270 | local iter = getiter(t) 271 | for _, v in iter(t) do 272 | if not fn(v) then return false end 273 | end 274 | return true 275 | end 276 | 277 | 278 | function lume.any(t, fn) 279 | fn = iteratee(fn) 280 | local iter = getiter(t) 281 | for _, v in iter(t) do 282 | if fn(v) then return true end 283 | end 284 | return false 285 | end 286 | 287 | 288 | function lume.reduce(t, fn, first) 289 | local started = first ~= nil 290 | local acc = first 291 | local iter = getiter(t) 292 | for _, v in iter(t) do 293 | if started then 294 | acc = fn(acc, v) 295 | else 296 | acc = v 297 | started = true 298 | end 299 | end 300 | assert(started, "reduce of an empty table with no first value") 301 | return acc 302 | end 303 | 304 | 305 | function lume.unique(t) 306 | local rtn = {} 307 | for k in pairs(lume.invert(t)) do 308 | rtn[#rtn + 1] = k 309 | end 310 | return rtn 311 | end 312 | 313 | 314 | function lume.filter(t, fn, retainkeys) 315 | fn = iteratee(fn) 316 | local iter = getiter(t) 317 | local rtn = {} 318 | if retainkeys then 319 | for k, v in iter(t) do 320 | if fn(v) then rtn[k] = v end 321 | end 322 | else 323 | for _, v in iter(t) do 324 | if fn(v) then rtn[#rtn + 1] = v end 325 | end 326 | end 327 | return rtn 328 | end 329 | 330 | 331 | function lume.reject(t, fn, retainkeys) 332 | fn = iteratee(fn) 333 | local iter = getiter(t) 334 | local rtn = {} 335 | if retainkeys then 336 | for k, v in iter(t) do 337 | if not fn(v) then rtn[k] = v end 338 | end 339 | else 340 | for _, v in iter(t) do 341 | if not fn(v) then rtn[#rtn + 1] = v end 342 | end 343 | end 344 | return rtn 345 | end 346 | 347 | 348 | function lume.merge(...) 349 | local rtn = {} 350 | for i = 1, select("#", ...) do 351 | local t = select(i, ...) 352 | local iter = getiter(t) 353 | for k, v in iter(t) do 354 | rtn[k] = v 355 | end 356 | end 357 | return rtn 358 | end 359 | 360 | 361 | function lume.concat(...) 362 | local rtn = {} 363 | for i = 1, select("#", ...) do 364 | local t = select(i, ...) 365 | if t ~= nil then 366 | local iter = getiter(t) 367 | for _, v in iter(t) do 368 | rtn[#rtn + 1] = v 369 | end 370 | end 371 | end 372 | return rtn 373 | end 374 | 375 | 376 | function lume.find(t, value) 377 | local iter = getiter(t) 378 | for k, v in iter(t) do 379 | if v == value then return k end 380 | end 381 | return nil 382 | end 383 | 384 | 385 | function lume.match(t, fn) 386 | fn = iteratee(fn) 387 | local iter = getiter(t) 388 | for k, v in iter(t) do 389 | if fn(v) then return v, k end 390 | end 391 | return nil 392 | end 393 | 394 | 395 | function lume.count(t, fn) 396 | local count = 0 397 | local iter = getiter(t) 398 | if fn then 399 | fn = iteratee(fn) 400 | for _, v in iter(t) do 401 | if fn(v) then count = count + 1 end 402 | end 403 | else 404 | if lume.isarray(t) then 405 | return #t 406 | end 407 | for _ in iter(t) do count = count + 1 end 408 | end 409 | return count 410 | end 411 | 412 | 413 | function lume.slice(t, i, j) 414 | i = i and absindex(#t, i) or 1 415 | j = j and absindex(#t, j) or #t 416 | local rtn = {} 417 | for x = i < 1 and 1 or i, j > #t and #t or j do 418 | rtn[#rtn + 1] = t[x] 419 | end 420 | return rtn 421 | end 422 | 423 | 424 | function lume.first(t, n) 425 | if not n then return t[1] end 426 | return lume.slice(t, 1, n) 427 | end 428 | 429 | 430 | function lume.last(t, n) 431 | if not n then return t[#t] end 432 | return lume.slice(t, -n, -1) 433 | end 434 | 435 | 436 | function lume.invert(t) 437 | local rtn = {} 438 | for k, v in pairs(t) do rtn[v] = k end 439 | return rtn 440 | end 441 | 442 | 443 | function lume.pick(t, ...) 444 | local rtn = {} 445 | for i = 1, select("#", ...) do 446 | local k = select(i, ...) 447 | rtn[k] = t[k] 448 | end 449 | return rtn 450 | end 451 | 452 | 453 | function lume.keys(t) 454 | local rtn = {} 455 | local iter = getiter(t) 456 | for k in iter(t) do rtn[#rtn + 1] = k end 457 | return rtn 458 | end 459 | 460 | 461 | function lume.clone(t) 462 | local rtn = {} 463 | for k, v in pairs(t) do rtn[k] = v end 464 | return rtn 465 | end 466 | 467 | 468 | function lume.fn(fn, ...) 469 | assert(iscallable(fn), "expected a function as the first argument") 470 | local args = { ... } 471 | return function(...) 472 | local a = lume.concat(args, { ... }) 473 | return fn(unpack(a)) 474 | end 475 | end 476 | 477 | 478 | function lume.once(fn, ...) 479 | local f = lume.fn(fn, ...) 480 | local done = false 481 | return function(...) 482 | if done then return end 483 | done = true 484 | return f(...) 485 | end 486 | end 487 | 488 | 489 | local memoize_fnkey = {} 490 | local memoize_nil = {} 491 | 492 | function lume.memoize(fn) 493 | local cache = {} 494 | return function(...) 495 | local c = cache 496 | for i = 1, select("#", ...) do 497 | local a = select(i, ...) or memoize_nil 498 | c[a] = c[a] or {} 499 | c = c[a] 500 | end 501 | c[memoize_fnkey] = c[memoize_fnkey] or {fn(...)} 502 | return unpack(c[memoize_fnkey]) 503 | end 504 | end 505 | 506 | 507 | function lume.combine(...) 508 | local n = select('#', ...) 509 | if n == 0 then return noop end 510 | if n == 1 then 511 | local fn = select(1, ...) 512 | if not fn then return noop end 513 | assert(iscallable(fn), "expected a function or nil") 514 | return fn 515 | end 516 | local funcs = {} 517 | for i = 1, n do 518 | local fn = select(i, ...) 519 | if fn ~= nil then 520 | assert(iscallable(fn), "expected a function or nil") 521 | funcs[#funcs + 1] = fn 522 | end 523 | end 524 | return function(...) 525 | for _, f in ipairs(funcs) do f(...) end 526 | end 527 | end 528 | 529 | 530 | function lume.call(fn, ...) 531 | if fn then 532 | return fn(...) 533 | end 534 | end 535 | 536 | 537 | function lume.time(fn, ...) 538 | local start = os.clock() 539 | local rtn = {fn(...)} 540 | return (os.clock() - start), unpack(rtn) 541 | end 542 | 543 | 544 | local lambda_cache = {} 545 | 546 | function lume.lambda(str) 547 | if not lambda_cache[str] then 548 | local args, body = str:match([[^([%w,_ ]-)%->(.-)$]]) 549 | assert(args and body, "bad string lambda") 550 | local s = "return function(" .. args .. ")\nreturn " .. body .. "\nend" 551 | lambda_cache[str] = lume.dostring(s) 552 | end 553 | return lambda_cache[str] 554 | end 555 | 556 | 557 | local serialize 558 | 559 | local serialize_map = { 560 | [ "boolean" ] = tostring, 561 | [ "nil" ] = tostring, 562 | [ "string" ] = function(v) return string.format("%q", v) end, 563 | [ "number" ] = function(v) 564 | if v ~= v then return "0/0" -- nan 565 | elseif v == 1 / 0 then return "1/0" -- inf 566 | elseif v == -1 / 0 then return "-1/0" end -- -inf 567 | return tostring(v) 568 | end, 569 | [ "table" ] = function(t, stk) 570 | stk = stk or {} 571 | if stk[t] then error("circular reference") end 572 | local rtn = {} 573 | stk[t] = true 574 | for k, v in pairs(t) do 575 | rtn[#rtn + 1] = "[" .. serialize(k, stk) .. "]=" .. serialize(v, stk) 576 | end 577 | stk[t] = nil 578 | return "{" .. table.concat(rtn, ",") .. "}" 579 | end 580 | } 581 | 582 | setmetatable(serialize_map, { 583 | __index = function(_, k) error("unsupported serialize type: " .. k) end 584 | }) 585 | 586 | serialize = function(x, stk) 587 | return serialize_map[type(x)](x, stk) 588 | end 589 | 590 | function lume.serialize(x) 591 | return serialize(x) 592 | end 593 | 594 | 595 | function lume.deserialize(str) 596 | return lume.dostring("return " .. str) 597 | end 598 | 599 | 600 | function lume.split(str, sep) 601 | if not sep then 602 | return lume.array(str:gmatch("([%S]+)")) 603 | else 604 | assert(sep ~= "", "empty separator") 605 | local psep = patternescape(sep) 606 | return lume.array((str..sep):gmatch("(.-)("..psep..")")) 607 | end 608 | end 609 | 610 | 611 | function lume.trim(str, chars) 612 | if not chars then return str:match("^[%s]*(.-)[%s]*$") end 613 | chars = patternescape(chars) 614 | return str:match("^[" .. chars .. "]*(.-)[" .. chars .. "]*$") 615 | end 616 | 617 | 618 | function lume.wordwrap(str, limit) 619 | limit = limit or 72 620 | local check 621 | if type(limit) == "number" then 622 | check = function(s) return #s >= limit end 623 | else 624 | check = limit 625 | end 626 | local rtn = {} 627 | local line = "" 628 | for word, spaces in str:gmatch("(%S+)(%s*)") do 629 | local s = line .. word 630 | if check(s) then 631 | table.insert(rtn, line .. "\n") 632 | line = word 633 | else 634 | line = s 635 | end 636 | for c in spaces:gmatch(".") do 637 | if c == "\n" then 638 | table.insert(rtn, line .. "\n") 639 | line = "" 640 | else 641 | line = line .. c 642 | end 643 | end 644 | end 645 | table.insert(rtn, line) 646 | return table.concat(rtn) 647 | end 648 | 649 | 650 | function lume.format(str, vars) 651 | if not vars then return str end 652 | local f = function(x) 653 | return tostring(vars[x] or vars[tonumber(x)] or "{" .. x .. "}") 654 | end 655 | return (str:gsub("{(.-)}", f)) 656 | end 657 | 658 | 659 | function lume.trace(...) 660 | local info = debug.getinfo(2, "Sl") 661 | local t = { info.short_src .. ":" .. info.currentline .. ":" } 662 | for i = 1, select("#", ...) do 663 | local x = select(i, ...) 664 | if type(x) == "number" then 665 | x = string.format("%g", lume.round(x, .01)) 666 | end 667 | t[#t + 1] = tostring(x) 668 | end 669 | print(table.concat(t, " ")) 670 | end 671 | 672 | 673 | function lume.dostring(str) 674 | return assert((loadstring or load)(str))() 675 | end 676 | 677 | 678 | function lume.uuid() 679 | local fn = function(x) 680 | local r = math.random(16) - 1 681 | r = (x == "x") and (r + 1) or (r % 4) + 9 682 | return ("0123456789abcdef"):sub(r, r) 683 | end 684 | return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn)) 685 | end 686 | 687 | 688 | function lume.hotswap(modname) 689 | local oldglobal = lume.clone(_G) 690 | local updated = {} 691 | local function update(old, new) 692 | if updated[old] then return end 693 | updated[old] = true 694 | local oldmt, newmt = getmetatable(old), getmetatable(new) 695 | if oldmt and newmt then update(oldmt, newmt) end 696 | for k, v in pairs(new) do 697 | if type(v) == "table" then update(old[k], v) else old[k] = v end 698 | end 699 | end 700 | local err = nil 701 | local function onerror(e) 702 | for k in pairs(_G) do _G[k] = oldglobal[k] end 703 | err = lume.trim(e) 704 | end 705 | local ok, oldmod = pcall(require, modname) 706 | oldmod = ok and oldmod or nil 707 | xpcall(function() 708 | package.loaded[modname] = nil 709 | local newmod = require(modname) 710 | if type(oldmod) == "table" then update(oldmod, newmod) end 711 | for k, v in pairs(oldglobal) do 712 | if v ~= _G[k] and type(v) == "table" then 713 | update(v, _G[k]) 714 | _G[k] = v 715 | end 716 | end 717 | end, onerror) 718 | package.loaded[modname] = oldmod 719 | if err then return nil, err end 720 | return oldmod 721 | end 722 | 723 | 724 | local ripairs_iter = function(t, i) 725 | i = i - 1 726 | local v = t[i] 727 | if v ~= nil then 728 | return i, v 729 | end 730 | end 731 | 732 | function lume.ripairs(t) 733 | return ripairs_iter, t, (#t + 1) 734 | end 735 | 736 | 737 | function lume.color(str, mul) 738 | mul = mul or 1 739 | local r, g, b, a 740 | r, g, b = str:match("#(%x%x)(%x%x)(%x%x)") 741 | if r then 742 | r = tonumber(r, 16) / 0xff 743 | g = tonumber(g, 16) / 0xff 744 | b = tonumber(b, 16) / 0xff 745 | a = 1 746 | elseif str:match("rgba?%s*%([%d%s%.,]+%)") then 747 | local f = str:gmatch("[%d.]+") 748 | r = (f() or 0) / 0xff 749 | g = (f() or 0) / 0xff 750 | b = (f() or 0) / 0xff 751 | a = f() or 1 752 | else 753 | error(("bad color string '%s'"):format(str)) 754 | end 755 | return r * mul, g * mul, b * mul, a * mul 756 | end 757 | 758 | 759 | local chain_mt = {} 760 | chain_mt.__index = lume.map(lume.filter(lume, iscallable, true), 761 | function(fn) 762 | return function(self, ...) 763 | self._value = fn(self._value, ...) 764 | return self 765 | end 766 | end) 767 | chain_mt.__index.result = function(x) return x._value end 768 | 769 | function lume.chain(value) 770 | return setmetatable({ _value = value }, chain_mt) 771 | end 772 | 773 | setmetatable(lume, { 774 | __call = function(_, ...) 775 | return lume.chain(...) 776 | end 777 | }) 778 | 779 | 780 | return lume 781 | -------------------------------------------------------------------------------- /narrator/parser.lua: -------------------------------------------------------------------------------- 1 | local lume = require('narrator.libs.lume') 2 | local enums = require('narrator.enums') 3 | 4 | -- 5 | -- LPeg 6 | 7 | -- To allow to build in Defold 8 | local lpeg_name = 'lpeg' 9 | 10 | if not pcall(require, lpeg_name) then 11 | return false 12 | end 13 | 14 | local lpeg = require(lpeg_name) 15 | 16 | local S, C, P, V = lpeg.S, lpeg.C, lpeg.P, lpeg.V 17 | local Cb, Ct, Cc, Cg = lpeg.Cb, lpeg.Ct, lpeg.Cc, lpeg.Cg 18 | local Cmt = lpeg.Cmt 19 | 20 | lpeg.locale(lpeg) 21 | 22 | -- 23 | -- Parser 24 | 25 | local parser = { } 26 | local constructor = { } 27 | 28 | ---Parse ink content string 29 | ---@param content string 30 | ---@return Narrator.Book 31 | function parser.parse(content) 32 | 33 | -- 34 | -- Basic patterns 35 | 36 | local function get_length(array) return 37 | #array 38 | end 39 | 40 | local eof = -1 41 | local sp = S(' \t') ^ 0 42 | local ws = S(' \t\r\n') ^ 0 43 | local nl = S('\r\n') ^ 1 44 | local none = Cc(nil) 45 | 46 | local divert_sign = P'->' 47 | local gather_mark = sp * C('-' - divert_sign) 48 | local gather_level = Cg(Ct(gather_mark ^ 1) / get_length + none, 'level') 49 | 50 | local sticky_marks = Cg(Ct((sp * C('+')) ^ 1) / get_length, 'level') * Cg(Cc(true), 'sticky') 51 | local choice_marks = Cg(Ct((sp * C('*')) ^ 1) / get_length, 'level') * Cg(Cc(false), 'sticky') 52 | local choice_level = sticky_marks + choice_marks 53 | 54 | local id = (lpeg.alpha + '_') * (lpeg.alnum + '_') ^ 0 55 | local label = Cg('(' * sp * C(id) * sp * ')', 'label') 56 | local address = id * ('.' * id) ^ -2 57 | 58 | ---Something for tunnels 59 | local function check_tunnel(s, i, a) 60 | local r = lpeg.match (sp * divert_sign, s, i) 61 | return i, r ~= nil 62 | end 63 | 64 | -- TODO: Clean divert expression to divert and tunnel 65 | local divert = divert_sign * sp * Cg(address, 'path') -- base search for divert symbol and path to follow 66 | local check_tunnel = Cg(Cmt(Cb('path'), check_tunnel), 'tunnel') -- a weird way to to check tunnel 67 | local opt_tunnel_sign = (sp * divert_sign * sp * (#nl + #S'#') ) ^ -1 -- tunnel sign in end of string, keep newline not consumed 68 | divert = Cg(Ct(divert * sp * check_tunnel * opt_tunnel_sign), 'divert') 69 | 70 | local divert_to_nothing = divert_sign * none 71 | local exit_tunnel = Cg(divert_sign * divert_sign, 'exit') 72 | local tag = '#' * sp * V'text' 73 | local tags = Cg(Ct(tag * (sp * tag) ^ 0), 'tags') 74 | 75 | local todo = sp * 'TODO:' * (1 - nl) ^ 0 76 | local comment_line = sp * '//' * sp * (1 - nl) ^ 0 77 | local comment_multi = sp * '/*' * ((P(1) - '*/') ^ 0) * '*/' 78 | local comment = comment_line + comment_multi 79 | 80 | local multiline_end = ws * '}' 81 | 82 | -- 83 | -- Dynamic patterns and evaluation helpers 84 | 85 | local function item_type(type) 86 | return Cg(Cc(type), 'type') 87 | end 88 | 89 | local function balanced_multiline_item(is_restricted) 90 | local is_restricted = is_restricted ~= nil and is_restricted or false 91 | local paragraph = is_restricted and V'restricted_paragraph' or V'paragraph' 92 | return sp * paragraph ^ -1 * sp * V'multiline_item' * sp * paragraph ^ -1 * ws 93 | end 94 | 95 | local function sentence_before(excluded, tailed) 96 | local tailed = tailed or false 97 | local character = P(1 - S(' \t')) - excluded 98 | local pattern = (sp * character ^ 1) ^ 1 99 | local with_tail = C(pattern * sp) 100 | local without_tail = C(pattern) * sp 101 | local without_tail_always = C(pattern) * sp * #(tags + nl) 102 | return without_tail_always + (tailed and with_tail or without_tail) 103 | end 104 | 105 | local function unwrap_assignment(assignment) 106 | local unwrapped = assignment 107 | unwrapped = unwrapped:gsub('([%w_]*)%s*([%+%-])[%+%-]', '%1 = %1 %2 1') 108 | unwrapped = unwrapped:gsub('([%w_]*)%s*([%+%-])=%s*(.*)', '%1 = %1 %2 %3') 109 | local name, value = unwrapped:match('([%w_]*)%s*=%s*(.*)') 110 | return name or '', value or assignment 111 | end 112 | 113 | local function check_special_escape(s, i, a) 114 | if string.sub(s, i - 2, i - 2) == '\\' then 115 | return 116 | end 117 | 118 | return i 119 | end 120 | 121 | -- 122 | -- Grammar rules 123 | 124 | local ink_grammar = P({ 'root', 125 | 126 | -- Root 127 | 128 | root = ws * V'items' + eof, 129 | items = Ct(V'item' ^ 0), 130 | 131 | item = balanced_multiline_item() + V'singleline_item', 132 | singleline_item = sp * (V'global' + V'statement' + V'paragraph' + V'gatherPoint') * ws, 133 | multiline_item = ('{' * sp * (V'sequence' + V'switch') * sp * multiline_end) - V'inline_condition', 134 | 135 | -- Gather points 136 | gatherPoint = Ct(gather_level * sp * nl * item_type('gather')), 137 | 138 | -- Global declarations 139 | 140 | global = 141 | Ct(V'inclusion' * item_type('inclusion')) + 142 | Ct(V'list' * item_type('list')) + 143 | Ct(V'constant' * item_type('constant')) + 144 | Ct(V'variable' * item_type('variable')) 145 | , 146 | 147 | inclusion = 'INCLUDE ' * sp * Cg(sentence_before(nl + comment), 'filename'), 148 | list = 'LIST ' * sp * V'assignment_pair', 149 | constant = 'CONST ' * sp * V'assignment_pair', 150 | variable = 'VAR ' * sp * V'assignment_pair', 151 | 152 | -- Statements 153 | 154 | statement = 155 | Ct(V'return_from_func' * item_type('return')) + 156 | Ct(V'assignment' * item_type('assignment')) + 157 | Ct(V'func' * item_type('func')) + 158 | Ct(V'knot' * item_type('knot')) + 159 | Ct(V'stitch' * item_type('stitch')) + 160 | Ct(V'choice' * item_type('choice')) + 161 | comment + todo 162 | , 163 | 164 | section_name = C(id) * sp * P'=' ^ 0, 165 | knot = P'==' * (P'=' ^ 0) * sp * Cg(V'section_name', 'knot'), 166 | stitch = '=' * sp * Cg(V'section_name', 'stitch'), 167 | 168 | func_param = sp * C(id) * sp * S','^0, 169 | func_params = P'(' * Cg(Ct(V'func_param'^0), 'params') * P')', 170 | function_name = P'function' * sp * Cg(id, 'name') * sp * V'func_params' * sp * P'=' ^ 0, 171 | func = P'==' * (P'=' ^ 0) * sp * Cg(Ct(V'function_name'), 'func'), 172 | 173 | return_from_func = sp * '~' * sp * P('return') * sp * Cg((P(1) - nl)^0, 'value') * nl ^ 0, 174 | 175 | assignment = gather_level * sp * '~' * sp * V'assignment_temp' * sp * V'assignment_pair', 176 | assignment_temp = Cg('temp' * Cc(true) + Cc(false), 'temp'), 177 | assignment_pair = Cg(sentence_before(nl + comment) / unwrap_assignment, 'name') * Cg(Cb('name') / 2, 'value'), 178 | 179 | choice_condition = Cg(V'expression' + none, 'condition'), 180 | choice_fallback = choice_level * sp * V'label_optional' * sp * V'choice_condition' * sp * (divert + divert_to_nothing) * sp * V'tags_optional', 181 | choice_normal = choice_level * sp * V'label_optional' * sp * V'choice_condition' * sp * Cg(V'text', 'text') * divert ^ -1 * sp * V'tags_optional', 182 | choice = V'choice_fallback' + V'choice_normal', 183 | 184 | -- Paragraph 185 | 186 | paragraph = Ct(gather_level * sp * (V'paragraph_label' + V'paragraph_text' + V'paragraph_tags') * item_type('paragraph')), 187 | paragraph_label = label * sp * Cg(V'text_optional', 'parts') * sp * V'tags_optional', 188 | paragraph_text = V'label_optional' * sp * Cg(V'text_complex', 'parts') * sp * V'tags_optional', 189 | paragraph_tags = V'label_optional' * sp * Cg(V'text_optional', 'parts') * sp * tags, 190 | 191 | label_optional = label + none, 192 | text_optional = V'text_complex' + none, 193 | tags_optional = tags + none, 194 | 195 | text_complex = Ct((Ct( 196 | Cg(V'inline_condition', 'condition') + 197 | Cg(V'inline_sequence', 'sequence') + 198 | Cg(V'expression', 'expression') + 199 | Cg(V'text' + ' ', 'text') * (exit_tunnel ^ -1) * (divert ^ -1) + exit_tunnel + divert 200 | ) - V'multiline_item') ^ 1), 201 | 202 | special_check_escape = Cmt(S("{|}"), check_special_escape), 203 | 204 | text = sentence_before(nl + exit_tunnel + divert + comment + tag + V'special_check_escape', true) - V'statement', 205 | -- Inline expressions, conditions, sequences 206 | 207 | expression = '{' * sp * sentence_before('}' + nl) * sp * '}', 208 | 209 | inline_condition = '{' * sp * Ct(V'inline_if_else' + V'inline_if') * sp * '}', 210 | inline_if = Cg(sentence_before(S':}' + nl), 'condition') * sp * ':' * sp * Cg(V'text_complex', 'success'), 211 | inline_if_else = (V'inline_if') * sp * '|' * sp * Cg(V'text_complex', 'failure'), 212 | 213 | inline_alt_empty = Ct(Ct(Cg(sp * Cc'', 'text') * sp * divert ^ -1)), 214 | inline_alt = V'text_complex' + V'inline_alt_empty', 215 | inline_alts = Ct(((sp * V'inline_alt' * sp * '|') ^ 1) * sp * V'inline_alt'), 216 | inline_sequence = '{' * sp * ( 217 | '!' * sp * Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('once'), 'sequence')) + 218 | '&' * sp * Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('cycle'), 'sequence')) + 219 | '~' * sp * Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('stopping'), 'sequence') * Cg(Cc(true), 'shuffle')) + 220 | Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('stopping'), 'sequence')) 221 | ) * sp * '}', 222 | 223 | -- Multiline conditions and switches 224 | 225 | switch = Ct((V'switch_comparative' + V'switch_conditional') * item_type('switch')), 226 | 227 | switch_comparative = Cg(V'switch_condition', 'expression') * ws * Cg(Ct((sp * V'switch_case') ^ 1), 'cases'), 228 | switch_conditional = Cg(Ct(V'switch_cases_headed' + V'switch_cases_only'), 'cases'), 229 | 230 | switch_cases_headed = V'switch_if' * ((sp * V'switch_case') ^ 0), 231 | switch_cases_only = ws * ((sp * V'switch_case') ^ 1), 232 | 233 | switch_if = Ct(Cg(V'switch_condition', 'condition') * ws * Cg(Ct(V'switch_items'), 'node')), 234 | switch_case = ('-' - divert_sign) * sp * V'switch_if', 235 | switch_condition = sentence_before(':' + nl) * sp * ':' * sp * comment ^ -1, 236 | switch_items = (V'restricted_item' - V'switch_case') ^ 1, 237 | 238 | -- Multiline sequences 239 | 240 | sequence = Ct((V'sequence_params' * sp * nl * sp * V'sequence_alts') * item_type('sequence')), 241 | 242 | sequence_params = ( 243 | V'sequence_shuffle_optional' * sp * V'sequence_type' + 244 | V'sequence_shuffle' * sp * V'sequence_type' + 245 | V'sequence_shuffle' * sp * V'sequence_type_optional' 246 | ) * sp * ':' * sp * comment ^ -1, 247 | 248 | sequence_shuffle_optional = V'sequence_shuffle' + Cg(Cc(false), 'shuffle'), 249 | sequence_shuffle = Cg(P'shuffle' / function() return true end, 'shuffle'), 250 | 251 | sequence_type_optional = V'sequence_type' + Cg(Cc'cycle', 'sequence'), 252 | sequence_type = Cg(P'cycle' + 'stopping' + 'once', 'sequence'), 253 | 254 | sequence_alts = Cg(Ct((sp * V'sequence_alt') ^ 1), 'alts'), 255 | sequence_alt = ('-' - divert_sign) * ws * Ct(V'sequence_items'), 256 | sequence_items = (V'restricted_item' - V'sequence_alt') ^ 1, 257 | 258 | -- Restricted items inside multiline items 259 | 260 | restricted_item = balanced_multiline_item(true) + V'restricted_singleline_item', 261 | restricted_singleline_item = sp * (V'global' + V'restricted_statement' + V'restricted_paragraph' - multiline_end) * ws, 262 | 263 | restricted_statement = Ct( 264 | V'choice' * item_type('choice') + 265 | V'assignment' * item_type('assignment') 266 | ) + comment + todo, 267 | 268 | restricted_paragraph = Ct(( 269 | Cg(V'text_complex', 'parts') * sp * V'tags_optional' + 270 | Cg(V'text_optional', 'parts') * sp * tags 271 | ) * item_type('paragraph')) 272 | 273 | }) 274 | 275 | -- 276 | -- Result 277 | 278 | local parsed_items = ink_grammar:match(content) 279 | local book = constructor.construct_book(parsed_items) 280 | return book 281 | end 282 | 283 | -- 284 | -- A book construction 285 | 286 | function constructor.unescape(text) 287 | local result = text 288 | 289 | result = result:gsub('\\|', '|') 290 | result = result:gsub('\\{', '{') 291 | result = result:gsub('\\}', '}') 292 | 293 | return result 294 | end 295 | 296 | function constructor.construct_book(items) 297 | 298 | local construction = { 299 | current_knot = '_', 300 | current_stitch = '_', 301 | variables_to_compute = { } 302 | } 303 | 304 | construction.book = { 305 | inclusions = { }, 306 | lists = { }, 307 | constants = { }, 308 | variables = { }, 309 | params = { }, 310 | tree = { _ = { _ = { } } } 311 | } 312 | 313 | construction.book.version = { 314 | engine = enums.engine_version, 315 | tree = 1 316 | } 317 | 318 | construction.nodes_chain = { 319 | construction.book.tree[construction.current_knot][construction.current_stitch] 320 | } 321 | 322 | constructor.add_node(construction, items) 323 | constructor.clear(construction.book.tree) 324 | constructor.compute_variables(construction) 325 | 326 | return construction.book 327 | end 328 | 329 | function constructor:add_node(items, is_restricted) 330 | local is_restricted = is_restricted ~= nil and is_restricted or false 331 | 332 | for _, item in ipairs(items) do 333 | if is_restricted then 334 | -- Are not allowed inside multiline blocks by Ink rules: 335 | -- a) nesting levels 336 | -- b) choices without diverts 337 | 338 | item.level = nil 339 | if item.type == 'choice' and item.divert == nil then 340 | item.type = nil 341 | end 342 | end 343 | 344 | if item.type == 'inclusion' then 345 | -- filename 346 | constructor.add_inclusion(self, item.filename) 347 | elseif item.type == 'list' then 348 | -- name, value 349 | constructor.add_list(self, item.name, item.value) 350 | elseif item.type == 'constant' then 351 | -- name, value 352 | constructor.add_constant(self, item.name, item.value) 353 | elseif item.type == 'variable' then 354 | -- name, value 355 | constructor.add_variable(self, item.name, item.value) 356 | elseif item.type == 'func' then 357 | -- function 358 | constructor.add_function(self, item.func.name, item.func.params) 359 | elseif item.type == 'knot' then 360 | -- knot 361 | constructor.add_knot(self, item.knot) 362 | elseif item.type == 'stitch' then 363 | -- stitch 364 | constructor.add_stitch(self, item.stitch) 365 | elseif item.type == 'switch' then 366 | -- expression, cases 367 | constructor.add_switch(self, item.expression, item.cases) 368 | elseif item.type == 'sequence' then 369 | -- sequence, shuffle, alts 370 | constructor.add_sequence(self, item.sequence, item.shuffle, item.alts) 371 | elseif item.type == 'assignment' then 372 | -- level, name, value, temp 373 | constructor.add_assignment(self, item.level, item.name, item.value, item.temp) 374 | elseif item.type == 'return' then 375 | constructor.add_return(self, item.value) 376 | elseif item.type == 'paragraph' then 377 | -- level, label, parts, tags 378 | constructor.add_paragraph(self, item.level, item.label, item.parts, item.tags) 379 | elseif item.type == 'gather' then 380 | constructor.add_paragraph(self, item.level, "", nil, item.tags) 381 | elseif item.type == 'choice' then 382 | -- level, sticky, label, condition, text, divert, tags 383 | constructor.add_choice(self, item.level, item.sticky, item.label, item.condition, item.text, item.divert, item.tags) 384 | end 385 | end 386 | end 387 | 388 | function constructor:add_inclusion(filename) 389 | table.insert(self.book.inclusions, filename) 390 | end 391 | 392 | function constructor:add_list(name, value) 393 | local items = lume.array(value:gmatch('[%w_%.]+')) 394 | self.book.lists[name] = items 395 | 396 | local switched = lume.array(value:gmatch('%b()')) 397 | switched = lume.map(switched, function(item) return item:sub(2, #item - 1) end) 398 | self.book.variables[name] = { [name] = { } } 399 | lume.each(switched, function(item) self.book.variables[name][name][item] = true end) 400 | end 401 | 402 | function constructor:add_constant(constant, value) 403 | local value = lume.deserialize(value) 404 | self.book.constants[constant] = value 405 | end 406 | 407 | function constructor:add_variable(variable, value) 408 | self.variables_to_compute[variable] = value 409 | end 410 | 411 | function constructor:add_function(fname, params) 412 | local node = { } 413 | self.book.tree[fname] = { ['_'] = node } 414 | self.book.params[fname] = params 415 | self.nodes_chain = { node } 416 | end 417 | 418 | function constructor:add_knot(knot) 419 | self.current_knot = knot 420 | self.current_stitch = '_' 421 | 422 | local node = { } 423 | self.book.tree[self.current_knot] = { [self.current_stitch] = node } 424 | self.nodes_chain = { node } 425 | end 426 | 427 | function constructor:add_stitch(stitch) 428 | -- If a root stitch is empty we need to add a divert to the first stitch in the ink file. 429 | if self.current_stitch == '_' then 430 | local root_stitch_node = self.book.tree[self.current_knot]._ 431 | if #root_stitch_node == 0 then 432 | local divertItem = { divert = { path = stitch } } 433 | table.insert(root_stitch_node, divertItem) 434 | end 435 | end 436 | 437 | self.current_stitch = stitch 438 | 439 | local node = { } 440 | self.book.tree[self.current_knot][self.current_stitch] = node 441 | self.nodes_chain = { node } 442 | end 443 | 444 | function constructor:add_switch(expression, cases) 445 | if expression then 446 | -- Convert switch cases to comparing conditions with expression 447 | for _, case in ipairs(cases) do 448 | if case.condition ~= 'else' then 449 | case.condition = expression .. '==' .. case.condition 450 | end 451 | end 452 | end 453 | 454 | local item = { 455 | condition = { }, 456 | success = { } 457 | } 458 | 459 | for _, case in ipairs(cases) do 460 | if case.condition == 'else' then 461 | local failure_node = { } 462 | table.insert(self.nodes_chain, failure_node) 463 | constructor.add_node(self, case.node, true) 464 | table.remove(self.nodes_chain) 465 | item.failure = failure_node 466 | else 467 | local success_node = { } 468 | table.insert(self.nodes_chain, success_node) 469 | constructor.add_node(self, case.node, true) 470 | table.remove(self.nodes_chain) 471 | table.insert(item.success, success_node) 472 | table.insert(item.condition, case.condition) 473 | end 474 | end 475 | 476 | constructor.add_item(self, nil, item) 477 | end 478 | 479 | function constructor:add_sequence(sequence, shuffle, alts) 480 | local item = { 481 | sequence = sequence, 482 | shuffle = shuffle and true or nil, 483 | alts = { } 484 | } 485 | 486 | for _, alt in ipairs(alts) do 487 | local alt_node = { } 488 | table.insert(self.nodes_chain, alt_node) 489 | constructor.add_node(self, alt, true) 490 | table.remove(self.nodes_chain) 491 | table.insert(item.alts, alt_node) 492 | end 493 | 494 | constructor.add_item(self, nil, item) 495 | end 496 | 497 | function constructor:add_return(value) 498 | local item = { 499 | return_value = value 500 | } 501 | 502 | constructor.add_item(self, nil, item) 503 | end 504 | 505 | function constructor:add_assignment(level, name, value, temp) 506 | local item = { 507 | temp = temp or nil, 508 | var = name, 509 | value = value 510 | } 511 | 512 | constructor.add_item(self, level, item) 513 | end 514 | 515 | function constructor:add_paragraph(level, label, parts, tags) 516 | local items = constructor.convert_paragraph_parts_to_items(parts, true) 517 | items = items or { } 518 | 519 | -- If the paragraph has a label or tags we need to place them as the first text item. 520 | if label ~= nil or tags ~= nil then 521 | local first_item 522 | 523 | if #items > 0 and items[1].condition == nil then 524 | first_item = items[1] 525 | else 526 | first_item = { } 527 | table.insert(items, first_item) 528 | end 529 | 530 | first_item.label = label 531 | first_item.tags = tags 532 | end 533 | 534 | for _, item in ipairs(items) do 535 | constructor.add_item(self, level, item) 536 | end 537 | end 538 | 539 | function constructor.convert_paragraph_parts_to_items(parts, is_root) 540 | if parts == nil then return nil end 541 | 542 | local is_root = is_root ~= nil and is_root or false 543 | local items = { } 544 | local item 545 | 546 | for index, part in ipairs(parts) do 547 | 548 | if part.condition then -- Inline condition part 549 | 550 | item = { 551 | condition = part.condition.condition, 552 | success = constructor.convert_paragraph_parts_to_items(part.condition.success), 553 | failure = constructor.convert_paragraph_parts_to_items(part.condition.failure) 554 | } 555 | 556 | table.insert(items, item) 557 | item = nil 558 | 559 | elseif part.sequence then -- Inline sequence part 560 | 561 | item = { 562 | sequence = part.sequence.sequence, 563 | shuffle = part.sequence.shuffle and true or nil, 564 | alts = { } 565 | } 566 | 567 | for _, alt in ipairs(part.sequence.alts) do 568 | table.insert(item.alts, constructor.convert_paragraph_parts_to_items(alt)) 569 | end 570 | 571 | table.insert(items, item) 572 | item = nil 573 | 574 | else -- Text, expression and divert may be 575 | 576 | local is_divert_only = part.divert ~= nil and part.text == nil 577 | 578 | if item == nil then 579 | item = { text = (is_root or is_divert_only) and '' or '<>' } 580 | end 581 | 582 | if part.text then 583 | item.text = item.text .. part.text:gsub('%s+', ' ') 584 | item.text = constructor.unescape(item.text) 585 | elseif part.expression then 586 | item.text = item.text .. '#' .. part.expression .. '#' 587 | end 588 | 589 | if part.divert or part.exit then 590 | item.exit = part.exit and true or nil 591 | item.divert = part.divert 592 | item.text = #item.text > 0 and (item.text .. '<>') or nil 593 | table.insert(items, item) 594 | item = nil 595 | else 596 | local next = parts[index + 1] 597 | local next_is_block = next and not (next.text or next.expression) 598 | 599 | if not next or next_is_block then 600 | if not is_root or next_is_block then 601 | item.text = item.text .. '<>' 602 | end 603 | table.insert(items, item) 604 | item = nil 605 | end 606 | end 607 | 608 | end 609 | end 610 | 611 | if is_root then 612 | -- Add a safe prefix and suffix for correct conditions gluing 613 | 614 | local first_item = items[1] 615 | if first_item.text == nil and first_item.divert == nil and first_item.exit == nil then 616 | table.insert(items, 1, { text = '' } ) 617 | end 618 | 619 | local last_item = items[#items] 620 | if last_item.text == nil and last_item.divert == nil and last_item.exit == nil then 621 | table.insert(items, { text = '' } ) 622 | elseif last_item.text ~= nil and last_item.divert == nil then 623 | last_item.text = last_item.text:gsub('(.-)%s*$', '%1') 624 | end 625 | end 626 | 627 | return items 628 | end 629 | 630 | function constructor:add_choice(level, sticky, label, condition, sentence, divert, tags) 631 | local item = { 632 | sticky = sticky or nil, 633 | condition = condition, 634 | label = label, 635 | divert = divert, 636 | tags = tags 637 | } 638 | 639 | if sentence == nil then 640 | item.choice = 0 641 | else 642 | local prefix, divider, suffix = sentence:match('(.*)%[(.*)%](.*)') 643 | prefix = prefix or sentence 644 | divider = divider or '' 645 | suffix = suffix or '' 646 | 647 | local text = (prefix .. suffix):gsub('%s+', ' ') 648 | local choice = (prefix .. divider):gsub('%s+', ' '):gsub('^%s*(.-)%s*$', '%1') 649 | 650 | if divert and #text > 0 and text:match('%S+') then 651 | text = text .. '<>' 652 | else 653 | text = text:gsub('^%s*(.-)%s*$', '%1') 654 | end 655 | 656 | item.text = constructor.unescape(text) 657 | item.choice = constructor.unescape(choice) 658 | end 659 | 660 | constructor.add_item(self, level, item) 661 | 662 | if divert == nil then 663 | item.node = { } 664 | table.insert(self.nodes_chain, item.node) 665 | end 666 | end 667 | 668 | function constructor:add_item(level, item) 669 | local level = (level ~= nil and level > 0) and level or #self.nodes_chain 670 | while #self.nodes_chain > level do 671 | table.remove(self.nodes_chain) 672 | end 673 | 674 | local node = self.nodes_chain[#self.nodes_chain] 675 | table.insert(node, item) 676 | end 677 | 678 | function constructor:compute_variable(variable, value) 679 | local constant = self.book.constants[value] 680 | if constant then 681 | self.book.variables[variable] = constant 682 | return 683 | end 684 | 685 | local list_expression = value:match('%(([%s%w%.,_]*)%)') 686 | local item_expressions = list_expression and lume.array(list_expression:gmatch('[%w_%.]+')) or { value } 687 | local list_variable = list_expression and { } or nil 688 | 689 | for _, item_expression in ipairs(item_expressions) do 690 | local list_part, item_part = item_expression:match('([%w_]+)%.([%w_]+)') 691 | item_part = item_part or item_expression 692 | 693 | for list_name, list_items in pairs(self.book.lists) do 694 | local list_is_valid = list_part == nil or list_part == list_name 695 | local item_is_found = lume.find(list_items, item_part) 696 | 697 | if list_is_valid and item_is_found then 698 | list_variable = list_variable or { } 699 | list_variable[list_name] = list_variable[list_name] or { } 700 | list_variable[list_name][item_part] = true 701 | end 702 | end 703 | end 704 | 705 | if list_variable then 706 | self.book.variables[variable] = list_variable 707 | else 708 | self.book.variables[variable] = lume.deserialize(value) 709 | end 710 | end 711 | 712 | function constructor:compute_variables() 713 | for variable, value in pairs(self.variables_to_compute) do 714 | constructor.compute_variable(self, variable, value) 715 | end 716 | end 717 | 718 | function constructor.clear(tree) 719 | for knot, node in pairs(tree) do 720 | for stitch, node in pairs(node) do 721 | constructor.clear_node(node) 722 | end 723 | end 724 | end 725 | 726 | function constructor.clear_node(node) 727 | for index, item in ipairs(node) do 728 | 729 | -- Simplify text only items 730 | if item.text ~= nil and lume.count(item) == 1 then 731 | node[index] = item.text 732 | end 733 | 734 | if item.node ~= nil then 735 | -- Clear choice nodes 736 | if #item.node == 0 then 737 | item.node = nil 738 | else 739 | constructor.clear_node(item.node) 740 | end 741 | 742 | end 743 | 744 | if item.success ~= nil then 745 | -- Simplify single condition 746 | if type(item.condition) == 'table' and #item.condition == 1 then 747 | item.condition = item.condition[1] 748 | end 749 | 750 | -- Clear success nodes 751 | if item.success[1] ~= nil and item.success[1][1] ~= nil then 752 | for index, success_node in ipairs(item.success) do 753 | constructor.clear_node(success_node) 754 | if #success_node == 1 and type(success_node[1]) == 'string' then 755 | item.success[index] = success_node[1] 756 | end 757 | end 758 | 759 | if #item.success == 1 then 760 | item.success = item.success[1] 761 | end 762 | else 763 | constructor.clear_node(item.success) 764 | if #item.success == 1 and type(item.success[1]) == 'string' then 765 | item.success = item.success[1] 766 | end 767 | end 768 | 769 | -- Clear failure nodes 770 | if item.failure ~= nil then 771 | constructor.clear_node(item.failure) 772 | if #item.failure == 1 and type(item.failure[1]) == 'string' then 773 | item.failure = item.failure[1] 774 | end 775 | end 776 | end 777 | 778 | if item.alts ~= nil then 779 | for index, alt_node in ipairs(item.alts) do 780 | constructor.clear_node(alt_node) 781 | if #alt_node == 1 and type(alt_node[1]) == 'string' then 782 | item.alts[index] = alt_node[1] 783 | end 784 | end 785 | end 786 | end 787 | end 788 | 789 | return parser --------------------------------------------------------------------------------