YarnSpinnerTool / YarnSpinner

Yarn Spinner is a tool for building interactive dialogue in games!
https://yarnspinner.dev
MIT License
2.3k stars 201 forks source link

Proposal: User-defined types #345

Closed st-pasha closed 7 months ago

st-pasha commented 1 year ago

Introduction

Similar to how users can extend Yarn with custom commands and functions, this feature would allow users to define variables of custom (user-defined) types.

Rationale

Currently, Yarn language supports 3 types only: bool, numeric, and string. Which may be fine for some purposes, but too limited for others. There are situations where users may want to have more advanced types: List, Set, Map, GameObject, Item, Quest, Location, Time, etc. Instead of supporting all this variety natively, we can allow the developer to define their own variable types, as many as they need.

I believe this feature would have very large benefit for the developers, while having a relatively small impact on the Yarn language itself.

Proposed solution (Detailed design)

In Yarn, we introduce a new variable type: object(T), where T is a string representing the backend type of the variable. A type T must be explicitly registered with the YarnProject for a variable of type object(T) being usable; though it is up to the implementation whether to enforce this restriction at compile- or run-time.

There are no literals corresponding to object(T). Instead, values of this type can be returned from user-defined functions, or passed to user-defined functions. The compiler should distinguish between variables of type object(T1) and object(T2) -- these should be considered as distinct types for the purpose of type checking.

When the developer defines the type T, they have an opportunity to provide a list of user-defined functions associated with this type. These functions will be called "methods", and they are namespaced to T, in the sense that different types T1 and T2 are allowed to have methods with same names.

The Yarn language introduces a special . syntax for invoking methods:

$x.foo($a, $b)

is equivalent to calling function foo($x, $a, $b), where foo is looked up in the namespace of $x's type.

Some of the methods defined for type T can have special names: operator+, operator*, operator==, operator<, etc. These will be used for defining operators between values of types object(T): $x + $y, $x == $y, etc. A binary operation will be considered valid if the corresponding "operator" method is defined for type T. In addition:

(Optional) it may be useful to extend the method-invocation syntax to the current types numeric, bool, and string as well; allowing for example to write $value.round() instead of round($value) for a numeric $value. This is intended to serve as an additional syntactic sugar without replacing the existing syntax.

It would also be useful to be able to pass these variables into user-defined commands. Currently this wouldn't be possible since the arguments of a user-defined command are parsed as a dialogue line, and any variables should be embedded as an interpolated expression, which causes them to be stringified. However, stringifying an object is lossy: it is not possible to get the original variable value back.

In order to solve this problem, we could add the following rule to how the user-defined commands are parsed: after the argument string is evaluated normally and then broken into chunks at whitespace, we check whether any of the fragments looks like a variable (i.e. $ followed by an ID). If yes, then that fragment is replaced with the value of the variable. For example:

<<accept $quest>>

Under the current rules this would invoke user-defined command accept("$quest"), under the new rules it would invoke accept(variableStorage.getValue("$quest")) -- allowing to have a user-defined command with an argument of type Quest.

Example

title: BlacksmithDialogue
---
<<local $items = equipped_items()>>  // type: List[Item]
<<local $broken = $items.broken()>>  // type: List[Item]
<<local $repair_cost = round($broken.value() / 5)>>

Blacksmith: Welcome back! How may I help you?
-> Repair my gear for {$repair_cost}[gp/] <<if $broken.isNotEmpty()>>
   <<if $money >= $repair_cost>>
     <<take gold $repair_cost>>
     <<repair $broken>>
     Blacksmith: There you go, your {$broken.describe()} is as good as new!
   <<else>>
     Blacksmith: Sorry but you don't seem to have enough money
   <<endif>>
-> Let me see your wares
   <<trade>>
===

Backwards Compatibility

The proposed feature is backward-compatible, since the only new syntax that it introduces (.) is currently not used within the context of an expression.

Alternatives considered

An alternative would be to keep the values in an external storage, and then provide numeric or string references to those values into the Yarn runtime.

In the example above, the function equipped_items() could store the actual value into the map extraValues, and then return the key to that value into Yarn (say, 1237). The method syntax would not work, but you can call function items_broken(1237) which would know that it has received a key and the actual value has to be looked up in the extraValues map.

The disadvantages of this approach are as follows:

Acknowledgments

McJones commented 7 months ago

Thanks for the proposal, you may have seen the recent Yarn Spinner 3 expectations announcement that there is a section on enums. Based on the excellent breakdown in this proposal we agree that expanding the available types in Yarn Spinner is desirable.

While enums aren't as advanced as what you are proposing we feel they capture a lot of the same expressiveness and flexibility. Thanks so much for the proposal.