-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbow.gd
More file actions
3412 lines (3366 loc) · 151 KB
/
bow.gd
File metadata and controls
3412 lines (3366 loc) · 151 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# BOW!
# ====
# The Arrow runtime in GDScript
# M. H. Golkar [2025, MIT]
## Bow the Arrow runtime in GDScript
##
## [b]Bow[/b] is the GDScript [b]Arrow[/b] runtime (and minimal dev kit / demo game)
## which can be used to develop narrative software, specially story focused games
## such as Visual Novels, Text & Graphic Adventures, etc. using Godot Engine (v4). [br]
##
## This module provides the core runtime, which is developed as a custom node for Godot. [br][br]
##
## For more information please check [url=https://github.com/mhgolkar/Bow]Bow[/url]
## and [url=https://github.com/mhgolkar/Arrow]Arrow[/url] repositories.
@tool
@icon("res://addons/bow/icon.svg")
extends Node
class_name Bow
## Signals change in the current [member chapter] being in the run.
signal chapter_opened
## Signals change in the [member state] of this runtime.
signal state_updated
## Signals new blocks of view being added to the [member queue] of this runtime.
signal queue_updated
## Signals a recommendation to the terminal/console to skip currently playing segment and try to pull freshly from the queue.
signal force_forward
## Signals end of an instruction chain where no further change has happened.
## In other words this is emitted after a set of instructions being run not ending in other signals.
## It [i]does not[/i] indicate whether the [member queue] is empty or not.
signal eol
@export_group("Chapters")
# ...
@export_subgroup("Listing")
## This property defines chapters list used by this runtime to jump between multiple Arrow documents. [br][br]
## NOTE: It is more common or convenient to provide a [member listing_file]
## and let the runtime to [method relist] chapters on [method begin] call.
@export var chapters_list: ChaptersList
## A file listing the chapters that this runtime should use. [br]
## Expected format is the same as a [i]projects[/i] file generated by Arrow editor. [br][br]
## CAUTION! Changing this property does not automatically update the [member chapters_list].
## You may call [method relist] if the [method begin] is already called.
@export_file("*.project", "*.json", "*.list") var listing_file: String;
## Path to the resource directory where chapter files are placed, that is used to find and read chapters on demand.
@export_dir var documents_dir: String
# ...
@export_subgroup("Entry")
## The first chapter (ID) to be loaded from which we may [method begin] our play. [br]
## Negative values mean to start from the first chapter (i.e. smallest ID) in the list.
## Undefined or wrong chapter IDs would result in error.
@export var entry_chapter: int = -1
## First node to start from when we [method begin] a run from [member entry_chapter] froward.
## Negative values mean to start from the default [code]entry[/code] point of the chapter.
@export var entry_node: int = -1
## Incoming slot of the [member entry_node] to be run. [br][br]
## NOTE: Unless you are using a modified project structure or custom nodes,
## it is just fine to leave it as is, to the `default = [constant DEFAULT_INCOMING_SLOT]`.
@export var entry_slot: int = DEFAULT_INCOMING_SLOT
@export_group("Runtime")
# ...
@export_subgroup("Play")
## Forces state of the game including [member variables] and [member characters] to be cleaned up
## every time we [method begin] a run. It is default behavior, because you are better to use
## Arrow jumps or to [method run] [code]FORWARD[/code] [Bow.Instruction]s
## to move between scenes and nodes even in case of in-game resets if state shall be kept.
@export var reset_state_on_begin: bool = true
# ...
@export_subgroup("Context and Translation")
## The way Bow creates view from chapters, which can be:[br]
## + TEXT where the original (text) content is used in the creation of view controls and voice alts, [br]
## + ID (default) where instead of the original text an identification key respecting the Arrow node's [code]UUID-kind-parameter[/code] is used.[br]
## They both go through the translation (and then exposure) system, and may be used as the keys, but if you plan not to add any translation at all,
## you may prefer [enum Ctx.TEXT] as it would work without any added i18n files.[br]
enum Ctx { ID, TEXT }
## Defines the way content is handled to create views from node,
## whether by using the original TEXT, or by addressing content with a [code]UUID-kind-parameter[/code] ID (for cleaner i18n).
@export var ctx: Ctx = Ctx.ID
## Bow tries to load respective translation files from this path for each one of the [member translation_locales] (or auto-detected ones).
## The placeholders "{lang}" (replaced by each local) and "{chapter}" (replaced by the filename of each chapter) may be used in the path.
## Default value would be fetched from [code]bow/chapters/i18n/path_format[/code] project setting.
@export var translations_path: StringName = ProjectSettings.get_setting_with_override("bow/chapters/i18n/path_format")
## Bow tries to load chapters translation files using [member translations_path] chapter file names and these locale identifiers.
## Leave it empty so the locales be auto-detected from [TranslationServer] respective to your project settings and Preferences.
@export var translation_locales: Array[StringName] = []
## Current loaded chapter ID or Title from the [member chapters_list]
var loaded = null
## Current [member loaded] chapter filename from [member chapters_list] used for asset management, etc.
var loaded_filename = null
## Current loaded chapter
var chapter: Chapter
## Currently open scopes (nested scenes/macros)
var scopes: Array[Scope]
## Current state of the runtime
var state: State
## The queue of [Block]s of [Element]s waiting to be shown to the player
var queue: Queue
## Default slot index used for incoming forwards when no specific value is defined.
const DEFAULT_INCOMING_SLOT: int = 0
## An invalid (non-existent) ID for a next node to which we may forward, resulting in EOL.
const INVALID_NODE_ID: int = -1;
## Extension of the Arrow documents used to create paths from chapter file names.
const ARROW_DOC_EXTENSION := ".arrow"
## Bow & Arrow Node
##
class ArwNode:
extends Resource
enum Kind {
CONDITION, CONTENT, DIALOG, ENTRY, FRAME, GENERATOR, HUB, INTERACTION,
JUMP, MACRO_USE, MARKER, MONOLOG, RANDOMIZER, SEQUENCER,
TAG_EDIT, TAG_MATCH, TAG_PASS, USER_INPUT, VARIABLE_UPDATE,
}
## ID of the wrapped node
var id: int
## Name of the wrapped node
var name: String
## The wrapped node
var node
## Creates an Arrow node object from a [code]resources.node[id][/code] object of an arrow document dictionary.
static func from_raw(id: int, node: Dictionary) -> ArwNode:
var this = ArwNode.new()
this.id = id
this.name = node.name
match node.type:
"condition":
this.node = Condition.from_raw(node.data)
"content":
this.node = Content.from_raw(node.data)
"dialog":
this.node = Dialog.from_raw(node.data)
"entry":
this.node = Entry.from_raw(node.data)
"frame":
this.node = Frame.from_raw(node.data)
"generator":
this.node = Generator.from_raw(node.data)
"hub":
this.node = Hub.from_raw(node.data)
"interaction":
this.node = Interaction.from_raw(node.data)
"jump":
this.node = Jump.from_raw(node.data)
"macro_use":
this.node = MacroUse.from_raw(node.data)
"marker":
this.node = Marker.from_raw(node.data)
"monolog":
this.node = Monolog.from_raw(node.data)
"randomizer":
this.node = Randomizer.from_raw(node.data)
"sequencer":
this.node = Sequencer.from_raw(node.data)
"tag_edit":
this.node = TagEdit.from_raw(node.data)
"tag_match":
this.node = TagMatch.from_raw(node.data)
"tag_pass":
this.node = TagPass.from_raw(node.data)
"user_input":
this.node = UserInput.from_raw(node.data)
"variable_update":
this.node = VariableUpdate.from_raw(node.data)
return this
## Helps printing the ArwNode resource.
func _to_string():
return "ArwNode #%s (%s): %s" % [id, name, node]
## Plays the wrapped [member node] and returns its instruction.
## NOTE: Playing a node does not change the runtime. The returned instructions should be used with [method Bow.run] to have an effect.
func play(map: NodeMap, current: State, ctx: Ctx, slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
print_debug("• Play { node = %s, map = %s }" % [self, map])
return self.node.instruct(self.id, map, current, ctx, slot_in)
# ...
# ...
## Arrow Condition Node
class Condition:
extends Resource
const KIND := Kind.CONDITION
enum PARAMETER_MODE { VALUE = 0, VARIABLE = 1 }
enum SLOT { FALSE = 0, TRUE = 1 }
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Condition:
var this = Condition.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Condition %s" % self.data
# This is a helper used by `evaluate_str_comparison` used when comparing length of two strings,
# returning parsed integer if the [code]string[/code] is a stringified int otherwise its length.
static func _length_from(string: String) -> int:
return int(string) if string == ("%s" % int(string)) else string.length()
## Compares two values of STR kind with the provided operation.
## Returns null if the evaluation is not possible (e.g. in case of bad inputs such as unknown operation).
static func evaluate_str_comparison(left: String, operation: String, right: String):
var result = null
match operation:
"rgx": # Matches RegEx Pattern
var regex = RegEx.new()
if ( regex.compile(right) == OK ):
# RegEx.search() returns RegExMatch if found, otherwise `null`
result = ( regex.search(left) != null )
"ct": # Contains Substring
result = (left.findn(right) >= 0)
"cts": # Contains Substring (Case-Sensitive)
result = (left.find(right) >= 0)
"eql": # Has Equal Length
result = (left.length() == _length_from(right))
"lng": # Is Longer
result = (left.length() > _length_from(right))
"shr": # Is Shorter
result = (left.length() < _length_from(right))
"bgn": # Begins with
result = left.begins_with(right)
"end": # Ends with
result = left.ends_with(right)
return result
## Compares two values of NUM kind with the provided operation.
## Returns null if the evaluation is not possible (e.g. in case of bad inputs such as unknown operation).
static func evaluate_num_comparison(left: int, operation: String, right: int):
var result = null
match operation:
"eq": # is Equal
result = ( left == right)
"nq": # is Not Equal
result = ( left != right)
"gt": # is Greater
result = ( left > right)
"gte": # is Greater or Equal
result = ( left >= right)
"ls": # is Lesser
result = ( left < right)
"lse": # is Lesser or Equal
result = ( left <= right)
return result
## Compares two values of BOOL kind with the provided operation.
## Returns null if the evaluation is not possible (e.g. in case of bad inputs such as unknown operation).
static func evaluate_bool_comparison(left: bool, operation: String, right: bool):
var result = null
match operation:
"eq":
result = (left == right)
"nq":
result = (left != right)
return result
## Returns bool on successful evaluation, otherwise null.
## It is not supposed to fail for valid nodes and properly initialized state, but if it does, returns null.
static func evaluate(data: Dictionary, current: Variables):
var result = null
if (
data.has_all(["variable", "operator", "with"]) &&
data.variable is int && data.variable >= 0 &&
data.operator is String &&
data.with is Array && data.with.size() >= 2
):
var left_var = current.fetch(data.variable)
if left_var != null:
var left = left_var.current_or_initial()
var operator = data.operator
var right = null
match data.with[0]:
PARAMETER_MODE.VALUE:
right = data.with[1]
PARAMETER_MODE.VARIABLE:
var right_var_id = data.with[1]
if right_var_id is int:
if right_var_id == data.variable:
# compared to itself (by convention the initial value)
right = left_var.init
else:
var right_var = current.fetch(right_var_id)
if right_var != null:
right = right_var.current_or_initial()
else:
printerr("invalid rhs: variable with ID defined by `data.with[1] = %s` is not initialized")
else:
printerr("invalid rhs: `data.with[1] = %s` expected to be var id (int) for the mode `data.with[0] = %s`" % data.with)
_:
printerr("unknown parameter mode (i.e. `data.with[0]`)")
# ...
if right != null:
match left_var.kind:
Variable.Kind.BOOL:
if left is bool && right is bool:
result = evaluate_bool_comparison(left, operator, right)
else:
printerr("incompatible hands for bool condition operation: ", [left, operator, right])
Variable.Kind.NUM:
if (left is int || left is float) && (right is int || right is float):
left = int(left)
right = int(right)
result = evaluate_num_comparison(left, operator, right)
else:
printerr("incompatible hands for num condition operation: ", [left, operator, right])
Variable.Kind.STR:
if left is String && right is String:
result = evaluate_str_comparison(left, operator, right)
else:
printerr("incompatible hands for str condition operation: ", [left, operator, right])
else:
printerr("condition node's target variable is un-initialized: ", data)
else:
printerr("condition node does not have valid required properties `variable >= 0`, `operator: string` or `with[mode,rhs]`: ", data)
return result
## Runs the node and returns a set to instruct runtime's next steps.
func instruct(_id: int, map: NodeMap, current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
var next: int
if map.skip:
next = SLOT.FALSE if map.has_connection(SLOT.FALSE) else SLOT.TRUE
else:
next = SLOT.TRUE if evaluate(self.data, current.variables) == true else SLOT.FALSE # if false or null
return [
map.forward_with(next)
]
# ...
## Arrow Content Node
class Content:
extends Resource
const KIND := Kind.CONTENT
const SINGULAR_SLOT_OUT := 0
const DEFAULT := {
"title": "", "content": "", # "brief": 0,
"auto": false, "clear": false,
}
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Content:
var this = Content.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Content %s" % self.data
## Converts a content node to an article element.
func process(node_id: int, ctx: Ctx) -> Dictionary:
var content: String; var title: String
match ctx:
Ctx.TEXT:
title = ("%s" % self.data.title) if self.data.has("title") else DEFAULT.title
content = ("%s" % self.data.content) if self.data.has("content") else DEFAULT.content
Ctx.ID:
title = "%s-content-title" % node_id
content = "%s-content-content" % node_id
var elements: Array[Element] = [ Element.article(content, title) ]
var voices: Array[Voice] = [ Voice.new(("%s" % node_id), [title, " ", content]) ]
return { "elements": elements, "voices": voices }
## Returns if this content is auto-play or not.
func is_auto() -> bool:
return self.data.auto if self.data.has("auto") && self.data.auto is bool else DEFAULT.auto
## Returns if this content should hint clearing the console before being printed or not.
func hints_clear() -> bool:
return self.data.clear if self.data.has("clear") && self.data.clear is bool else DEFAULT.clear
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(id: int, map: NodeMap, _current: State, ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
var next := map.forward_with(SINGULAR_SLOT_OUT)
if map.skip:
return [next]
else:
var processed = self.process(id, ctx)
var auto_play := self.is_auto()
if auto_play == false:
processed.elements.push_back(Element.next([ Instruction.ffw(), next ]))
var print := Instruction.print([
Block.new(id, processed.elements, processed.voices, null, self.hints_clear())
])
if auto_play:
return [print, next]
else:
return [print]
# ...
## Arrow Dialog Node
class Dialog:
extends Resource
const KIND := Kind.DIALOG
const DEFAULT := {
"character": -1, # ~ anonymous or unset (hardcoded convention)
"lines": ["Hey there!"],
# -- optional(s) --
"playable": false,
}
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Dialog:
var this = Dialog.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Dialog %s" % self.data
## Returns if this dialog is playable or not (~ automatic for NPC).
func is_playable() -> bool:
return self.data.playable if self.data.has("playable") && self.data.playable is bool else DEFAULT.playable
## Converts lines of dialog to Line elements with user interactivity.
## NOTE: We extract the mood only from the original chapter lines, so you don't need to keep them in the translations.
func as_playable(node_id: int, map: NodeMap, character: Character, ctx: Ctx) -> Array[Instruction]:
var lines = self.data.lines if self.data.has("lines") && self.data.lines is Array else DEFAULT.lines
var elements: Array[Element]
for line_index in range(0, lines.size()):
var line = lines[line_index]
if line is String && line.length() > 0:
var text: String
match ctx:
Ctx.TEXT:
text = line
Ctx.ID:
text = "%s-dialog-line-%s" % [node_id, line_index]
var interaction: Array[Instruction] = [ map.forward_with(line_index) ]
var mood = Element.mood(line)
var inter_elements: Array[Element]
if mood != null:
text = text.trim_prefix(mood.inner.parameters[3])
inter_elements.push_back(mood)
var inter_voices: Array[Voice] = [ Voice.new(("%s-%s" % [node_id, line_index]), [character.name, ": ", text]) ]
interaction.push_front( Instruction.print([ Block.new(node_id, inter_elements, inter_voices, character) ]) )
elements.push_back( Element.line(text, interaction) )
var instructions: Array[Instruction] = [ Instruction.print([ Block.new(node_id, elements, [], character) ]) ]
return instructions
## Converts a automatically/randomly chosen line of dialog to a Line elements without interactivity, i.e. as if it is already played.
## NOTE: We extract the mood only from the original chapter lines, so you don't need to keep them in the translations.
func as_auto(node_id: int, map: NodeMap, character: Character, ctx: Ctx) -> Array[Instruction]:
var lines = self.data.lines if self.data.has("lines") && self.data.lines is Array else DEFAULT.lines
var chosen := randi_range(0, lines.size() - 1)
var next = map.forward_with(chosen)
var instructions: Array[Instruction] = [next]
var line = lines[chosen]
if line is String && line.length() > 0:
var elements: Array[Element] = []
var text: String
match ctx:
Ctx.TEXT:
text = line
Ctx.ID:
text = "%s-dialog-line-%s" % [node_id, chosen]
var mood = Element.mood(line)
if mood != null:
text = text.trim_prefix(mood.inner.parameters[3])
elements.push_back(mood)
elements.push_back( Element.line(text) )
var voices: Array[Voice] = [ Voice.new(("%s-%s" % [node_id, chosen]), [character.name, ": ", text]) ]
instructions.push_front( Instruction.print([ Block.new(node_id, elements, voices, character) ]) )
return instructions
## Returns character owner of this dialog or anonymous if it's not listed.
func character(listed: Characters) -> Character:
var character_id = self.data.character if self.data.has("character") && self.data.character is int else DEFAULT.character
var found = listed.fetch(character_id)
if found == null && character_id > -1:
print_debug("WARN! dialog node with uninitialized character: ", self.data)
return found if found != null else Character.anonymous()
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(id: int, map: NodeMap, current: State, ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
if map.skip:
return [ map.forward_with_first() ]
else:
var character := self.character(current.characters)
return as_playable(id, map, character, ctx) if is_playable() else as_auto(id, map, character, ctx)
# ...
## Arrow Entry Node
class Entry:
extends Resource
const KIND := Kind.ENTRY
const SINGULAR_SLOT_OUT := 0
const DEFAULT := {
"plaque": "",
}
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Entry:
var this = Entry.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Entry %s" % self.data
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(id: int, map: NodeMap, _current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
# Entries always forward to their next node, that's their existential duty!
# If they have "plaque" string being set, they PRINT a Hook as well (before FORWARD),
# telegraphing the final printer that we are passing a special point in the story.
var plaque = self.data.plaque if self.data.has("plaque") && self.data.plaque is String else DEFAULT.plaque
var hook = null if plaque.length() == 0 else Instruction.print([ Block.new(id, [ Element.hook("entry", [plaque]) ]) ])
var next := map.forward_with(SINGULAR_SLOT_OUT)
return [next] if hook == null else [hook, next]
# ...
## Arrow Frame Node
class Frame:
extends Resource
const KIND := Kind.FRAME
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Frame:
var this = Frame.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "⚠ Frame %s" % self.data
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(id: int, _map: NodeMap, _current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
# Frames are Arrow editor's non-playable nodes designed for visual organization of nodes on its grid.
printerr("frame nodes (including this one %s) are not supposed to be played! It happens if you have a invalid Jump or mistakenly edited connection." % id, self.data)
# It is not supposed to be run! We just create an invalid forward to signal an EOL.
return [ NodeMap.eol() ]
# ...
## Arrow Generator Node
class Generator:
extends Resource
const KIND := Kind.GENERATOR
const SINGULAR_SLOT_OUT := 0
const VALID_METHODS_FOR_TYPE := {
Variable.Kind.NUM : [ "randi" ],
Variable.Kind.STR : [ "ascii", "strst" ],
Variable.Kind.BOOL: [ "rnbln" ]
}
const STRING_SET_DELIMITER := "|"
const DEFAULT_CHARACTER_POOL := "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz123456789"
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Generator:
var this = Generator.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Generator %s" % self.data
## Returns an instruction to update the target if everything's fine otherwise null.
func generate(current: Variables):
if data.has_all(["variable", "method"]) && data.variable is int:
var target = current.fetch(data.variable)
if target is Variable:
if VALID_METHODS_FOR_TYPE[target.kind].has(data.method):
var new_value = self.call(
("generate_%s" % data.method),
( data.arguments if data.has("arguments") else null)
)
if new_value != null:
return target.reset(new_value)
else:
print_debug("generator target is incompatible with the method: ", data)
else:
print_debug("generator target is not initialized: ", data)
else:
print_debug("generator has not required data fields variable and method: ", data)
return null
## Advance (pseudo-) random integer number generator.
static func _advance_random_integer(from: int = 0, to: int = 0, negative: bool = false, even: bool = false, odd: bool = false) -> int:
var result = null
if from >= to:
result = from
if (to - from) <= 1:
# we need at least one odd and one even number in the possibilities
to += 1
while result == null:
result = randi_range(from, to)
if even != odd : # to be either odd or even
# (both true or both false means ignore)
var is_even = (result % 2 == 0)
if is_even && even == false:
result = null
if is_even == false && odd == false:
result = null
if negative is bool && negative == true: # negative
result = (result * (-1))
# print_debug("_advance_random_integer(", from, to, negative, even, odd, ") -> ", result)
return result
## Generates random integer using arguments expected from a Generator node of type [code]randi[/code].
static func generate_randi(args) -> int:
var result = null
if args is Array && args.size() == 5:
if (
args[0] is int && args[0] >= 0 &&
args[1] is int && args[1] >= 1
):
result = _advance_random_integer(
args[0], args[1], # from, to,
args[2], # negative,
args[3], args[4] # even, odd
)
else:
print_debug("unexpected behavior! bad range for `randi` generator: ", args)
else:
print_debug("unexpected behavior! wrong number of arguments for `randi` generator: ", args)
if result == null:
result = (randi() % 100) + 1 # so returns even on corrupt arguments
return result
## Generates random ASCII string using arguments expected from a Generator node of type [code]ascii[/code].
static func generate_ascii(args) -> String:
var result := ""
if args.size() == 2:
var char_pool = (args[0] if (args[0] is String && args[0].length() > 0) else DEFAULT_CHARACTER_POOL)
var pool_size = char_pool.length()
var desired_length = (args[1] if args[1] is int && args[1] > 0 else pool_size)
while result.length() < desired_length:
result = ( result + char_pool.substr( (randi() % pool_size), 1) )
return result
## Takes random string from a set using arguments expected from a Generator node of type [code]strst[/code].
static func generate_strst(stringified_set) -> String:
var result := ""
if stringified_set is String && stringified_set.length() > 0:
var string_set = stringified_set.split(STRING_SET_DELIMITER, false)
if string_set.size() > 0 :
result = string_set[ randi() % string_set.size() ]
return result
## Generates random boolean for a Generator node of type [code]rnbln[/code].
static func generate_rnbln() -> bool:
return ( randi() % 2 == 0 )
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(id: int, map: NodeMap, current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
# Generator nodes forward to their next node anyway.
# Of course they first try to update their target, unless skipped or having wrong arguments.
var next := map.forward_with(SINGULAR_SLOT_OUT)
if map.skip:
return [next]
else:
var update = self.generate(current.variables)
if update == null:
printerr("generator node %s ran to no result (~ due to invalid arguments)!" % id)
return [next]
else:
return [update, next]
# ...
## Arrow Hub Node
class Hub:
extends Resource
const KIND := Kind.HUB
const SINGULAR_SLOT_OUT := 0
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Hub:
var this = Hub.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Hub %s" % self.data
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(_id: int, map: NodeMap, _current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
# Hub nodes are very simple. They merge multiple incoming slots to their only outgoing one.
# This is the normal behavior even if the node is skipped. No error is expected.
var next := map.forward_with(SINGULAR_SLOT_OUT)
return [next]
# ...
## Arrow Interaction Node
class Interaction:
extends Resource
const KIND := Kind.INTERACTION
const DEFAULT := {
"actions": ["Go ahead!"]
}
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Interaction:
var this = Interaction.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Interaction %s" % self.data
## Converts actions to Line elements.
func action_elements(node_id: int, map: NodeMap, ctx: Ctx) -> Array[Element]:
var actions = self.data.actions if self.data.has("actions") && self.data.actions is Array else DEFAULT.actions
var elements: Array[Element]
for action_index in range(0, actions.size()):
var action = actions[action_index]
if action is String && action.length() > 0:
var text: String
match ctx:
Ctx.TEXT:
text = action
Ctx.ID:
text = "%s-interaction-action-%s" % [node_id, action_index]
elements.push_back( Element.line(text, [ map.forward_with(action_index) ]) )
return elements
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(id: int, map: NodeMap, _current: State, ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
if map.skip:
return [ map.forward_with_first() ]
else:
return [ Instruction.print([ Block.new(id, self.action_elements(id, map, ctx)) ])]
# ...
## Arrow Jump Node
class Jump:
extends Resource
const KIND := Kind.JUMP
const DEFAULT := {
"target": INVALID_NODE_ID,
"reason": ""
}
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Jump:
var this = Jump.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Jump %s" % self.data
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(id: int, map: NodeMap, _current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
# Conventionally, skipped and unset jumps are handled as EOL (~ forwarding to INVALID_NODE_ID).
# Any other destination shall be handled by the runtime (even to non-existing target nodes).
# If they have "reason" string being set, they PRINT a Hook as well (before FORWARD),
# telegraphing the final printer that we are passing a special point in the story.
var target = self.data.target if self.data.has("target") && self.data.target is int else DEFAULT.target
if map.skip || target <= INVALID_NODE_ID:
return [ NodeMap.eol() ]
else:
var reason = self.data.reason if self.data.has("reason") && self.data.reason is String else DEFAULT.reason
var hook = null if reason.length() == 0 else Instruction.print([ Block.new(id, [ Element.hook("jump", [target, reason]) ]) ])
var next := Instruction.forward(target, DEFAULT_INCOMING_SLOT)
return [next] if hook == null else [hook, next]
# ...
## Arrow Macro-Use Node
class MacroUse:
extends Resource
const KIND := Kind.MACRO_USE
const SINGULAR_SLOT_OUT := 0
const INVALID_MACRO_ID := -1
const DEFAULT := {
"macro": INVALID_MACRO_ID
}
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> MacroUse:
var this = MacroUse.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Macro-Use %s" % self.data
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(_id: int, map: NodeMap, _current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
# Conventionally, skipped and unset Macro-Use nodes forward their only slot out, as if they immediately resume.
# Seemingly valid macros will be tried as new scopes, with resume points that shall be handled by the runtime
# whether valid or not, which may conventionally be treated as immediate closure (similar to skip)
# for invalid/non-existent scopes or resumes.
var macro = self.data.macro if self.data.has("macro") && self.data.macro is int else DEFAULT.macro
if map.skip || macro <= INVALID_MACRO_ID:
var next := map.forward_with(SINGULAR_SLOT_OUT)
return [next]
else:
var resume_point = map.get_connection(SINGULAR_SLOT_OUT)
var scope = Instruction.scope(macro, [], [] if resume_point == null else resume_point)
return [scope]
# ...
## Arrow Marker Node
class Marker:
extends Resource
const KIND := Kind.MARKER
const SINGULAR_SLOT_OUT := 0
const HOOK_FORMAT_REGEX := r"(.*)\W*\[(.*)\]"
const HOOK_PARAMS_DELIMITER := "|"
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Marker:
var this = Marker.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Marker %s" % self.data
## Returns a Hook if the marker has [code]event[prams|...][/code] format, otherwise null.
func as_hook():
if self.data.has("label") && self.data.label is String && self.data.label.length() > 0:
var regex = RegEx.new()
var compiled = regex.compile(HOOK_FORMAT_REGEX)
if compiled == OK:
var matched = regex.search(self.data.label)
if matched != null && matched.get_string(0) == self.data.label: # exact pattern found
var event = matched.get_string(1)
if event.length() == 0:
print_debug("WARN! marker hook with blank event detected: ", self.data.label)
var serialized_joint_params = matched.get_string(2)
# We need to deserialize and parse non-text params as well:
var parameters := []
for param in serialized_joint_params.split(HOOK_PARAMS_DELIMITER):
var parsed
match param.to_lower():
"true":
parsed = true
"false":
parsed = false
"null":
parsed = null
_:
if param.is_valid_int():
parsed = param.to_int()
elif param.is_valid_float():
parsed = param.to_float()
else:
parsed = param
parameters.push_back(parsed)
# Conventionally, we pass the marker color if defined as the last parameter as well:
if self.data.has("color") && self.data.color.is_valid_html_color():
parameters.push_back(Color.html(self.data.color))
# ...
return Element.hook(event, parameters)
else:
printerr("unexpected behavior! HOOK_FORMAT_REGEX is supposed to always compile to valid regex!")
else:
print_debug("NOTE! marker with no label (or blank) is running. skip it if color only")
return null
## Runs the node and returns a set to instruct runtime moving forward.[br]
## Markers are commonly used to log info on the Arrow project's (editor) grid,
## so in runtime they print a debug message and pass to their next node even skipped.[br]
## Bow runtime has an additional behavior though, for an special kind of markers called Hook Markers.
## Any marker with a label of [code]event[prams|...][/code], if not skipped, will PRINT a respective [Bow.Element.Hook].
## This allows uses of marker nodes to call gameplay functions from dot-arrow documents by interpreting hooks as serialized method calls.[br]
## Color of the marker if defined in the data will be passed as the last parameter.[br]
## NOTE: Parameters are split by [code]|[/code] (pipe) and empty is also allowed, so your event (method) can accepts them,
## which means you should be extra careful about double pipes. Note also that blank events may be supported but are not recommended.
func instruct(id: int, map: NodeMap, _current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
var hook = null if map.skip else self.as_hook()
var print = null if hook == null else Instruction.print([ Block.new(id, [hook]) ])
var next := map.forward_with(SINGULAR_SLOT_OUT)
return [next] if print == null else [print, next]
# ...
## Arrow Monolog Node
class Monolog:
extends Resource
const KIND := Kind.MONOLOG
const SINGULAR_SLOT_OUT := 0
const DEFAULT := {
"character": -1, # ~ anonymous or unset (hardcoded convention)
"monolog": "",
# -- optional(s) --
# "brief": 0,
"auto": false,
"clear": false,
}
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Monolog:
var this = Monolog.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Monolog %s" % self.data
## Converts a monolog node to the respective elements (including an article and a possible mood) and respective voices.
## NOTE: We extract the mood only from the original chapter monolog, so you don't need to keep them in the translations.
func process(node_id: int, character: Character, ctx: Ctx) -> Dictionary:
var monolog: String = ("%s" % self.data.monolog) if self.data.has("monolog") else DEFAULT.monolog
var text: String
match ctx:
Ctx.TEXT:
text = monolog
Ctx.ID:
text = "%s-monolog-monolog" % node_id
var mood = Element.mood(monolog)
if mood != null:
text = text.trim_prefix(mood.inner.parameters[3])
var elements: Array[Element] = [ Element.article(text) ]
if mood != null:
elements.push_front(mood)
var voices: Array[Voice] = [ Voice.new(("%s" % node_id), [character.name, ": ", text]) ]
return { "elements": elements, "voices": voices }
## Returns if this monolog is auto-play or not.
func is_auto() -> bool:
return self.data.auto if self.data.has("auto") && self.data.auto is bool else DEFAULT.auto
## Returns if this monolog should hint clearing the console before being printed or not.
func hints_clear() -> bool:
return self.data.clear if self.data.has("clear") && self.data.clear is bool else DEFAULT.clear
## Returns character owner of this monolog or anonymous if it's not listed.
func character(listed: Characters) -> Character:
var character_id = self.data.character if self.data.has("character") && self.data.character is int else DEFAULT.character
var found = listed.fetch(character_id)
if found == null && character_id > -1:
print_debug("WARN! monolog node with uninitialized character: ", self.data)
return found if found != null else Character.anonymous()
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(id: int, map: NodeMap, current: State, ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
var next := map.forward_with(SINGULAR_SLOT_OUT)
if map.skip:
return [next]
else:
var character := self.character(current.characters)
var processed := self.process(id, character, ctx)
var auto_play := self.is_auto()
if auto_play == false:
processed.elements.push_back(Element.next([ Instruction.ffw(), next ]))
var print := Instruction.print([
Block.new(
id,
processed.elements, processed.voices,
character,
self.hints_clear()
)
])
if auto_play:
return [print, next]
else:
return [print]
# ...
## Arrow Randomizer Node
class Randomizer:
extends Resource
const KIND := Kind.RANDOMIZER
const MINIMUM_ACCEPTABLE_OUT_SLOTS := 2
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Randomizer:
var this = Randomizer.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Randomizer %s" % self.data
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(_id: int, map: NodeMap, _current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
# Randomizer nodes are very simple. They dispatch incoming connection(s) (slot or jump)
# to one of their outgoing slots randomly chosen. This is the normal behavior even if the node is skipped.
# It is their destiny! Even if there are disconnected slots, they may forward to them, so to an EOL.
var slots = self.data.slots if self.data.has("slots") else MINIMUM_ACCEPTABLE_OUT_SLOTS
var next = map.forward_with(randi_range(0, slots - 1))
return [next]
# ...
## Arrow Sequencer Node
class Sequencer:
extends Resource
const KIND := Kind.SEQUENCER
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> Sequencer:
var this = Sequencer.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Sequencer %s" % self.data
## Runs the node and returns a set to instruct runtime moving forward.
func instruct(_id: int, map: NodeMap, _current: State, _ctx: Ctx, _slot_in: int = DEFAULT_INCOMING_SLOT) -> Array: # Array[Instruction]
if map.skip:
return [ map.forward_with_last() ]
else:
var sequence := map.forward_all()
return sequence if sequence.size() > 0 else [ NodeMap.eol() ]
# ...
## Arrow Tag-Edit Node
class TagEdit:
extends Resource
const KIND := Kind.TAG_EDIT
const SINGULAR_SLOT_OUT := 0
enum METHOD {
INSET = 0,
RESET = 1,
OVERSET = 2,
OUTSET = 3,
UNSET = 4,
}
## This node's raw data as defined in the Arrow document
var data
## Creates a node from raw [code]data[/code] of an Arrow node resource.
static func from_raw(data: Dictionary) -> TagEdit:
var this = TagEdit.new()
this.data = data
return this
## Helps printing this built-in node resource.
func _to_string():
return "Tag-Edit %s" % self.data
## Creates an updater respective to the inner value if valid, otherwise null.
func update(current: Characters):
if data.has_all(["character", "edit"]) && data.character is int && data.edit is Array && data.edit.size() == 3:
var target = current.fetch(data.character)
if target is Character:
var method = data.edit[0]
var name = data.edit[1] if data.edit[1] is String else ""
var value = data.edit[2] if data.edit[2] is String else ""
if name.length() > 0:
match method:
METHOD.INSET: # Adds a key:value tag, only if the key does not exist
if target.tags.has(name) == false:
return target.reset_tag(name, value)
METHOD.RESET: # Resets value of a tag, only if the key exists
if target.tags.has(name) == true:
return target.reset_tag(name, value)
METHOD.OVERSET: # Overwrites or adds a key:value tag, whether the key exists or not
return target.reset_tag(name, value)
METHOD.OUTSET: # Removes a tag if both key & value match
if target.tags.has(name) == true && target.tags[name] == value: