• 🏆 Texturing Contest #33 is OPEN! Contestants must re-texture a SD unit model found in-game (Warcraft 3 Classic), recreating the unit into a peaceful NPC version. 🔗Click here to enter!
  • ✅ The POLL for Hive's Texturing Contest #33 is OPEN! Vote for the TOP 3 SKINS! 🔗Click here to cast your vote!

Cutscene and Dialog System 0.0.5

This bundle is marked as pending. It has not been reviewed by a staff member yet.
LUA Declarative Cutscene & Dialog System
by Macielos

Let's say you wrote (or you plan to write) SHITLOADS of dialogs for your map or campaign in a form like below:

Arthas: I'm glad you could bake it, Uther.
Uther: Watch your tone with me, boy...

How much work do you need to actually turn it into a full-fledged in-game dialog or cutscene? Well, hell of a lot. That was my thought too and because I'm lazy, I decided to code something that will automagically manage cutscenes and dialogs for me, based on some configuration (a list of messages with some additional properties when necessary). In time, I added a lot more features to create a more interactive, RPG-style dialog structure in which you can make choices, react differently, have some extra topics you can ask NPCs about.

It's one of several larger pieces of code I'm working on for Exodus 2 campaign, the first bigger thing I coded in LUA (before I coded in Java, C#, Python and more) and so far the only one polished to a degree I'm not ashamed to show it to others. It still has some issues and you can surely expect me to work on it, but it's usable and already really time-saving, so I decided to share it to gather some feedback before you can see it in all its glory in Exodus 2.

===========

Features:

Declarative Dialog System
- Send lists of unit transmissions one after another, with custom dialog window, auto-management of display times and nice text rendering animation
- Automated scrolling up/down when your text is too long to fit
- Skip single lines immediately with RIGHT arrow when you're done reading or just want to get through a dialog faster without skipping the entirety of it
- Easily declare order of messages, branching, loops, resolving next message based on LUA functions or variables, allowing you to easily prepare highly interactive and non-linear dialog structures
- Involve choices in your dialogs - display a list of options and make a scene halt until you pick a dialog option (UP/DOWN arrows) and confirm it (RIGHT arrow)
- On each message, with delays if required, you can run your own trigger, custom function or even a list of actions
- Every message is logged into a transmission log (under F12) so you can re-read them later

Automated Cutscenes
- Playing a dialog with cutscene configuration requires FAR less code/trigger actions than preparing a cutscene trigger by hand
- You can handle cutscene logic (e.g. camera movements, units moving or turning to one another) by attaching triggers or custom functions to specific messages (with delays if needed)
- Unlike standard cutscenes, automated ones have user controls enabled so that the player can skip lines or make choices. A pleasant "side effect" is that you can pause a game, change options, see transmission log (with previous messages you missed), save and load a game during cutscenes. So far - surprisingly enough - I encountered no issues when loading a game in cutscene. You can easily disable this feature in configuration, but that way you won't have skipping lines or choices
- Automated cutscene skipping - you can either skip single lines with RIGHT arrow, or an entire cutscene with ESC. You no longer need to have if-skipped-then-skip-actions-else-do-nothing after every action in a cutscene trigger. You only need to implement a dialog ending trigger and the mid-game logic is attached to specific dialog lines. For actions happening in the midst of a dialog line you can use utils.timedSkippable(delay, function() ... end). You can also make certain parts of a dialog unskippable (I plan to add some icon to show when a player can skip).
- Fadeins/fadeouts handled by simply defining e.g. fadeOutDuration = 2.0 in a message and fadeInDuration = 2.0 in a following one

Dialog types and customization
- Use a prepared screenplay in a cutscene, in an RPG-style dialog or a simple non-interrupting list of messages.
- Use one of predefined screenplay variants, modify them or prepare your own. You can modify size and positions of UI elements, as well as dialog behaviour like whether or not to lock camera, pause units, show unit flashes or make cutscene interactive
- Just fair warning - not every combination of configuration parameters has been tested, some options are not intended to use together, e.g. simple dialogs will act weird with choices because right arrow also moves the camera right. I suggest to start with predefined configs, but feel free to experiment at your own risk and report suggestions how to make configuration more intuitive and less prone to errors

===========

Sources:
The source code and screenplays from demo are available on Github:
https://github.com/Macielos/Warcraft3LuaLibs

Getting Started:
I suggest to familiarize with the system on a demo map which tells a story of a fellow footman on an epic quest to find
beer. See the screenplays within the map triggers or on Github, then try to modify them and experiment. It's best to do
it in some IDE (I personally use IntelliJ Idea with LUA plugin) or text editor that highlights syntax, brackets,
does formatting etc. You can find a list of available fields and their descriptions in a file ScreenplaySystem.lua,
near the beginning. If there's demand, I can prepare some more detailed docs.

Usage:
- Download a demo map
- Copy Import folder
- Open your map, make sure you have LUA as your script language in map options
- Paste Import folder
- Prepare your screenplays and use them in triggers
- Enjoy

You can speed up working on screenplays by using a small tool I wrote (requires Java 18+, so far you need to build the
project yourself using Maven, I can provide a jar if there's any interest in the tool):
https://github.com/Macielos/ScreenplayGenerator/

Notes:
- This library overrides InitBlizzard() function to call custom initialization code
- This library was never meant to be used in multiplayer. Scenes are displayed for local player only and there is no data syncing mechanism. My guess would be that non-pausing config variants will work, but for pausing ones you'd have to handle pausing game for other players and events like: one player has a choice window open and the other one attacks him.

Compatibility:
- I'm currently using the library on Reforged only, but it should work on 1.31 as it only uses very basic native functions. Just make sure your map uses LUA. I remember that I ran some early versions on 1.31.

Known issues:
- You can unlock a camera in cutscene by pressing e.g. F10. To fix it I periodically 'adjust' the camera.
- Because of above, instead of standard camera functions/trigger actions you should use ScreenplayUtils.interpolateCamera(cameraFrom, cameraTo, duration) or ScreenplayUtils.interpolateCameraFromCurrent(cameraTo, duration). See elf screenplay in demo for examples.
- For some rare camera angles in cutscenes camera does not get locked at all, so you can freely move it. EDIT: I think it happens when you interpolate between cameras with the same target position (so they differ with only e.g. rotation)
- During in-game dialogs you can click on a window which disrupts a game a little. I wanted to make it not register clicks, but so far I didn't find an option for that (maybe someone here will give me a hint). In the worst case I'll just add a config to display in-game dialogs like in vanilla, without a dialog window.

Credits:
@Planetary - my system began as a modification of this system:
https://www.hiveworkshop.com/threads/lua-jrpg-dialogue-system-v1-3.327674/
In time I expanded and reworked it to a degree there's hardly any original code left, but I still use its UI files and some basic code structure. Planetary gave me permission to use fragments of his system. He said he doesn't need any credits, but his system is cool and gave me lots of inspiration, so I'm giving him credits anyway :p.

Changelog:
0.0.1
- initial version

0.0.2
[not published in demo map, only available on github]

0.0.3
note: changed some naming for clarity - skipping now means current mechanism of skipping single lines, rewinding means interrupting the whole scene
- New options in screenplay variants:
skippable - true/false - allows to skip single lines by RIGHT arrow
1. rewindable - true/false - allows to rewind to the next interactive element OR exits the dialog if there is none
2. disableSelection -true/false - disable selecting and highlightin units, showing healthbars etc. during a dialog or cutscene
3. interruptExisting - true/false - if true, starting a scene of this variant when another one is active will interrupt the existing one, if false, new scene won't be started. This was previously only available in a parametr when starting a scene. I typically have this to true for cutscenes and major dialogs, to false for minor interactions, plus I can still pass a parameter to starting a scene to override this one
4. removed lockControls flag as it was already covered by other flags
- New options for dialog items:
1. onRewindGoTo - used to rewind a dialog with choices, contains an index of next line like in goTo, thenGoTo etc. If there is a cutscene that is generally linear, but allows the player to ask some extra questions or get straight to the mission, we might want pressinc ESC not to take us to these choices, but simply interrupt the whole scene like in WC3 cutscenes. You can now do this but setting this parameter in choices.
2. stopOnRewind - true/false. You might want an opposite behaviour for any line, thus this new field. When set to true, rewinding will stop at this particular item so you need to click esc again to rewind further
3. skipTimers - true/false, now timers added via timedSkippable() function will be interrupted only for lines with skipTimers = true. In practice - set this to true for a first line in a new shot or scene
- Did some cleanups and refactoring after old libs I incorporated in the past - split old utils class onto several smaller utility classes, got rid of majority of global functions and moved 2 remaining ones to GlobalFunctions.lua, enclosed local functions in classes because apparently they are not actually local anyway
- Added Elkonium's DebugUtils and GameConsole to demo map. One I get familiarize with them more, I'll probably remove my own debugFuncs[/note]

0.0.4
- Added possibility to create actors without a physical unit by: ScreenplayFactory.createActorFromType(unitType, player, customName)
- Added important API functions summary and some more docs

0.0.5
- Got rid of two remaining global functions
- Changed funcs in ScreenplaySystem into local where possible
- Added scopes for main classes so that local functions are actually local
- Removed last usage of legacy interface, removed legacy interfaces from demo map (I still need them for now, but you don't ;))
Previews
Contents

Cutscene And Dialogue System 0.0.5 (Map)

Reviews
Antares
A lot of improvements, nice. The invisible frame bug doesn't appear anymore. I see a small issue with the text as it is being written that the word first appears in the old line, then jumps to a new one once it no longer fits. I fixed that in the...
Level 20
Joined
Oct 16, 2021
Messages
236
This is when the history of the bravest Legionnaire of the Enclave begins...

The thing works as fully-fledged replacement for obsolete dialogue system of WC3, is cool, is full of QoL, is idiot-proof and idiot-friendly. Tested on myself.
 

Macielos

Hosted Project: W3E
Level 20
Joined
Jul 9, 2010
Messages
263
This looks great. Do you think there's a way to fade in letters as they appear? Like in WoW quest texts?
A whole text, yes, single letters - problematic (using transparency in text tags doesn't work as far as I know). So far I'm focused on functional improvements, making things pretty is not my specialty :p.
 
Hey,

regarding the fading in of characters, I wrote this snippet as a proof of concept. Maybe you'd like to make something out of it. I really think it would make your system look great.

Lua:
if Debug then Debug.beginFile "WowQuestText" end
do
    local TEXT_WIDTH = 0.2
    local CHARACTERS_PER_SECOND = 25
    local FADE_IN_TIME = 0.6
    local TEXT_LINE_SPACING = 0.01
    local ALPHA_INCREMENT = 5

    local X_LEFT = 0.3
    local Y_TOP = 0.5

    local widthTestFrame = nil              ---@type framehandle

    function WriteQuestText(text)
        local charNum = 0
        local thisWord = text:sub(1, text:find(" ") - 1)
        local length = text:len()
        local remainingText
        local char
        local x, y = X_LEFT, Y_TOP

        TimerStart(CreateTimer(), 1/CHARACTERS_PER_SECOND, true, function()
            ::begin::
            charNum = charNum + 1

            if charNum > length then
                DestroyTimer(GetExpiredTimer())
                return
            end
            char = text:sub(charNum, charNum)

            if char == "\n" then
                x = X_LEFT
                y = y - TEXT_LINE_SPACING
                goto begin
            elseif char == " " then --Check if next word fits into current line
                remainingText = text:sub(charNum + 1, length)
                thisWord = remainingText:sub(1, (remainingText:find(" ") or remainingText:len() + 1) - 1) --Find next space or is last word.
                BlzFrameSetText(widthTestFrame, " " .. thisWord:gsub("\n", "")) --Width must include previous space.
                BlzFrameSetSize(widthTestFrame, 0, TEXT_LINE_SPACING)
                if x + BlzFrameGetWidth(widthTestFrame) > X_LEFT + TEXT_WIDTH then --Next line
                    x = X_LEFT
                    y = y - TEXT_LINE_SPACING
                    goto begin
                end
            end

            local newFrame = BlzCreateFrameByType("TEXT", "textFrame", BlzGetOriginFrame(ORIGIN_FRAME_WORLD_FRAME, 0), "", 0)
            BlzFrameSetAbsPoint(newFrame, FRAMEPOINT_BOTTOMLEFT, x, y)
            BlzFrameSetText(newFrame, char)
            BlzFrameSetSize(newFrame, 0, TEXT_LINE_SPACING)
            local alpha = 0
            BlzFrameSetAlpha(newFrame, 0)
            TimerStart(CreateTimer(), ALPHA_INCREMENT*FADE_IN_TIME/255, true, function()
                alpha = alpha + ALPHA_INCREMENT
                BlzFrameSetAlpha(newFrame, alpha)
                if alpha >= 255 then
                    DestroyTimer(GetExpiredTimer())
                end
            end)
            x = x + BlzFrameGetWidth(newFrame)
        end)
    end

    OnInit.final(function()
        widthTestFrame = BlzCreateFrameByType("TEXT", "textFrame", BlzGetOriginFrame(ORIGIN_FRAME_WORLD_FRAME, 0), "", 0)
        WriteQuestText([[There was a time when I was young and full of vigor like you, druid.

But now I'm old and unable to explore the world like I once did. Ah, the things I saw!

But there was one mystery that eluded me. For my final quest, the Explorers' League sent me in search of The Temple of Atal'Hakkar. It was rumored to be located in the Swamp of Sorrows.

Because of my frail state, I traveled the skies by gryphon in search of it but never found it.

Help an old dwarf out? Perhaps you will have better luck on foot.]])
    end)
end

Moving on to the review:

The documentation of your API could be better. You're mixing configurables with non-configurables and constants with no clear delimitation. Why is "onSceneEndTrigger" in the config, for example? As far as I can tell, that just seems to be an upvalue declaration for later.

You should also try to abide by the formatting conventions laid out here (globals UpperCase, locals lowerCase, constants CAPS_LOCK).

A global simply named "utils" could lead to name clashes. Consider renaming it or try to find a way to not make it a global. One way to do it is use Lua's _ENV feature. You also randomly have functions not in the utils table in that script file. It's a bit of a mess.

Because you have no enclosing scope around your script files, the local printDebug functions are visible everywhere.

On re-entering the rifleman beacon, the background frame of the dialog is transparent. That seems not intended.

My first instinct when the first multiple choice dialog popped up was to press the number keys to select the option. I think that would be a great addition. Also consider making the hotkeys customizable.

All in all, this is a fantastic system and easily a high quality rating once all the rough edges have been smoothed out. Looking forward to using this myself :psmile:.

Awaiting Update
 

Macielos

Hosted Project: W3E
Level 20
Joined
Jul 9, 2010
Messages
263
Thanks for feedback, I'll try to apply some fixes and refactors once I have a free moment.

The whole configuration is in ScreenplayVariants.lua, onSceneEndTrigger is just an internal variable. Perhaps you were misled by an old comment ("start of config settings") I forgot to remove once I extracted configuration from this class :p. I'll remove it.

Yeah, I admit I was a bit inconsistent with naming. Some were leftovers from the original library I was reworking, some (e.g. utils class) were left due to compatibility reasons, i.e. I have references to them in existing maps. Maybe I'll make proxy classes from them like I did with ScreenplayLegacyInterface.

As for keys, I used arrows mostly because simplicity and out of the box support in triggers without any additional library.
 
A lot of improvements, nice. The invisible frame bug doesn't appear anymore.

I see a small issue with the text as it is being written that the word first appears in the old line, then jumps to a new one once it no longer fits. I fixed that in the text system I mentioned last time by checking if the next word would fit in the current line first before starting to render it. If you wanna copy that solution, check it out here.

You have superfluous wrappers around your functions because some parts use the old function names? Can't you just go through your code with find and replace? :prazz:

I don't see a mention in your description about whether your system is working in multiplayer. I assume it is not?

You're overwriting InitBlizzard for your initialization. If you're overwriting a native, you have to declare that in the documentation. While it's not required, I encourage you to use TotalInitialization as your initialization method. This ensures that your library works well with other libraries (if someone else is initializing their libraries the same way, only one will work (edit: nvm, I'm mistaken)).

While the documentation of the ScreenplayVariants is good, the ScreenplaySystem library misses a listing of the API, preferably at the top of the script. Also, are any of these function supposed to be private?

Because of the last two issues, gonna have to put it back to awaiting update.
 
Last edited:

Macielos

Hosted Project: W3E
Level 20
Joined
Jul 9, 2010
Messages
263
Sorry, I haven't had much time to work on WC3 scripts recently. I added API list (not complete one, but with functions people will most likely use) and some more docs, including info about InitBlizzard and multi.

I see a small issue with the text as it is being written that the word first appears in the old line, then jumps to a new one once it no longer fits. I fixed that in the text system I mentioned last time by checking if the next word would fit in the current line first before starting to render it. If you wanna copy that solution, check it out here.
Thanks, I might use it once I get to beautification stage ;).

You have superfluous wrappers around your functions because some parts use the old function names? Can't you just go through your code with find and replace? :prazz:
I have replaced them in code long ago, but they are still used in multiple maps too. The libraries are in a single folder that I always copy between maps as a whole, but screenplays are kept near specific triggers, so I'd have to manually copy them file by file (unless there's some automagical hot-reload tool I am not aware of). So I did this workaround so that they work for now, and I will copy them lazily only when I make some changes in them. Believe me of not, this is a big time optimization ;).

This ensures that your library works well with other libraries (if someone else is initializing their libraries the same way, only one will work (edit: nvm, I'm mistaken)).
I think they will work no matter the order, each one will override the previous one.

While the documentation of the ScreenplayVariants is good, the ScreenplaySystem library misses a listing of the API, preferably at the top of the script. Also, are any of these function supposed to be private?
Well, some of these functions could indeed be private and they would be in a "normal" language. However, in my maps (where i have this lib + many others) I run into a limitation of max. 200 local functions, so I have to limit their usage. Besides, as far as I noticed, these local functions are not really local since they're compiled into one big script and their accessibility just depends on script order. Plus - that's my personal bias of course - I don't like the fact i need to declare a function higher than its invocation - it reduces code readibility.
 
I think they will work no matter the order, each one will override the previous one.
You're right. I figured that out after I posted my comment.

Well, some of these functions could indeed be private and they would be in a "normal" language. However, in my maps (where i have this lib + many others) I run into a limitation of max. 200 local functions, so I have to limit their usage. Besides, as far as I noticed, these local functions are not really local since they're compiled into one big script and their accessibility just depends on script order. Plus - that's my personal bias of course - I don't like the fact i need to declare a function higher than its invocation - it reduces code readibility.
I was talking about the fact that you have no API listing and you might have functions in your Screenplay table that are to be considered private, adding to the confusion.

I've run into the 200 locals limit as well while working on ALICE, but that is a much larger script than what you have here, so that's surprising to me. I also don't understand what you're saying about local functions are not local. Are you putting a scope around them with do end?

If you run into the problem of no-forward declaration, you can define the function variable as nil at the top of the scope, then overwrite it further down. Example:

Lua:
do
    local testFunc = nil
    
    local function CallTestFunc()
        testFunc()
    end

    testFunc = function() print("test succesful") end

    CallTestFunc()
end
 

Macielos

Hosted Project: W3E
Level 20
Joined
Jul 9, 2010
Messages
263
I was talking about the fact that you have no API listing and you might have functions in your Screenplay table that are to be considered private, adding to the confusion.
Okay, sorry for a long delay, I uploaded some refactors. I added scopes and made funcs local where possible, now that they are truly local. I also got rid of the remaining global funcs completely.

I mean, I know how to do a forward declaration, I just don't like it as it reduces code readability. In a OOP language you usually have public methods on top, which then call some private methods, which are below, and you read it like a book. Here I have to either jump up and down, OR have this forward declarations. It's not a blocker, of course, it's just annoying. But I guess I need to get rid of some biases from Java-like languages when coding in LUA.

I think in my next libs I'll limit usage of LUA classes and make them stateless, and keep all internal variables as local scoped. Right now fields of e.g. ScreenplaySystem are in fact public, but they needn't be.

I've run into the 200 locals limit as well while working on ALICE, but that is a much larger script than what you have here, so that's surprising to me.
I ran into this limit on another map, where I have this library plus several others, including e.g. lots of custom skills. I need to check out if putting local funcs within scopes make them not count to this limit.
 
Good progress on the code refactoring. It looks a lot cleaner! However
Lua:
onInit(function()
    ScreenplaySystem:init()
end)
A three line library? Can't this be part of the ScreenplaySystem script?

Lua:
ScreenplaySystem = {   -- main dialogue class, configuration options can be found in ScreenplayVariants.lua
    FDF_BACKDROP = "EscMenuBackdrop",
    FDF_TITLE = "CustomText", -- from imported .fdf
    FDF_TEXT_AREA = "CustomTextArea", -- ``
    TITLE_COLOR_HEX = "|cffffce22", -- character title text color.
    TEXT_COLOR_HEX = "|cffffffff", -- character speech text color.
    INACTIVE_CHOICE_COLOR_HEX = "|cff808080", -- greyish text color for choices other than the selected one

    debug = false, -- print debug messages for certain functions.

    --leftovers from original lib, could be moved to ScreenplayVariants, but so far I saw no need to customize them
    fade = true, -- should dialogue components have fading eye candy effects?
    fadeDuration = 0.81, -- how fast to fade if fade is enabled.

    --internal state of the config and the screenplay
    frameInitialized = false,
    currentVariantConfig = nil,
    onSceneEndTrigger = nil,
    messageUncoverTimer,
    trackingCameraTimer,
    autoplayTimer,
    cameraInterpolationTimer,
    delayTimer,
    fadeoutTimer,
    lastActorUnitTypeSpeaking,
}
You're not defining the fields messageUncoverTimer, trackingCameraTimer etc. here, but assigning undeclared globals to the sequence part of the table. This makes my VS Code go crazy with warnings. You need to add the = nil to each of them.

Lua:
function SimpleUtils.debugFunc(func, name)
    local name = name or ""
    local result
    local passed, data = pcall(function()
        result = func()
        return "func " .. name .. " passed"
    end)
    if not passed then
        print(name, passed, data)
    end
    passed = nil
    data = nil
    return result
end
You're generating a lot of anonymous functions throughout your code. You could try to reduce that on those that are called more often, like the debugFunc here.

I have the feeling that the ScreenplayFactory:saveBuilder function could be solved in a much simpler way with an optional name parameter in the builder functions.

The inconsistent use of dot and colon syntax is also something you should address. This can lead to a lot of headache for a user when using the wrong notation. For functions such as function ScreenplaySystem:init(), you could simply remove the colon and set local self = ScreenplaySystem in the first line of the function. Adding type notations with EmmyLUA would also help immensely.

Apart from these issues, I'm still missing an overarching documentation. You have a lot of different sub-libraries and it is not obvious what the exact responsibilities of each of them are to the uninitiated user. You have different snippets of information scattered throughout the hiveworkshop page, the test map triggers, and your code, but nothing that I can just read and follow to get started. I want to have a tutorial for "I have imported the libraries into my map. Now what?" It should guide me through step by step how I can create my first screenplay, list the API, and explain what the responsibilities of each library are.

A good documentation is really crucial for a resource this complex!
 
Top