Speakers
Four words cover the whole addon:
| Decides | |
|---|---|
| Conversation | what is said |
| Speakers | who says it |
| Dialogue Box | how it is presented |
| Placement | where it appears |
This page is the second one.
A speaker is a dialogue identity
Not a gameplay character. A TalkSpeaker holds a name, a face for the box, and a voice — and nothing else. No NodePath, no AnimationPlayer, no world sprite.
extends Node
## Docs: /guide/speakers — who is talking, how they look, how they sound.
func _ready() -> void:
_build()
#region speaker
# A speaker is a dialogue identity. No NodePath, no AnimationPlayer, no world
# sprite — so a narrator, a radio voice, or someone standing in a level that is
# not even loaded are all ordinary speakers.
func make_a_speaker() -> TalkSpeaker:
var haru := TalkSpeaker.new()
haru.speaker_id = &"haru"
haru.display_name = "Haru"
haru.visual = TalkDialogueVisual.new()
haru.visual.texture = preload("res://addons/npc_talkkit/icons/talk_speaker.svg")
haru.voice_blip = AudioStreamWAV.new()
return haru
#endregion
#region reuse
# Separate from the node is not the same as separate from the asset. Pointing a
# speaker at the very SpriteFrames the player already uses is the intended way
# to make the player a speaker — TalkKit never looks for the player node.
func reuse_the_game_art(frames: SpriteFrames) -> TalkSpeaker:
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
player.visual = TalkDialogueVisual.new()
player.visual.sprite_frames = frames
player.visual.animation = &"idle"
return player
#endregion
#region variants
# Named faces, not a free-text mood. A variant the speaker does not have falls
# back to their default face rather than drawing nothing.
func add_an_angry_face(haru: TalkSpeaker, angry: Texture2D) -> void:
var variant := TalkDialogueVisual.new()
variant.texture = angry
haru.variants[&"angry"] = variant
func shout_this_line(line: TalkLine) -> void:
line.visual_variant = &"angry"
#endregion
#region multi
# One conversation, several speakers, one straight sequence. The conversation
# says once who normally speaks; a line says so only when it is the exception,
# and the line after it goes back to the default without being told to.
func a_conversation_with_three_people(
haru: TalkSpeaker, guard: TalkSpeaker, player: TalkSpeaker, box: TalkBoxTemplate,
) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"gate"
conversation.default_speaker = haru
conversation.default_dialogue_box = box
for pair in [[null, "Who are you?"], [guard, "Step away."],
[player, "I am only passing through."], [null, "Let him go."]]:
var line := TalkLine.new()
if pair[0] != null:
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = pair[0]
line.text = pair[1]
conversation.lines.append(line)
return conversation
#endregion
#region nobody
# No speaker at all is a working configuration, not a gap. The text still
# types, events still fire; the name row and the visual just stay empty.
func a_sign_nobody_speaks(box: TalkBoxTemplate) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"sign"
conversation.default_speaker = null # nobody, and that is the point
conversation.default_dialogue_box = box
var line := TalkLine.new()
line.text = "[i]The gate is barred.[/i]"
conversation.lines = [line]
return conversation
#endregion
#region say
# Nothing has to be authored anywhere. say() takes a string, a TalkLine, or a
# sequence of them, and runs them through the pipeline play() uses — same box,
# same placement, same events.
func bark(text: String) -> void:
$NPCTalkKit.say(text)
func interrupt(guard: TalkSpeaker) -> void:
var line := TalkLine.new()
line.text = "[color=#ff6b6b]Stop right there![/color]"
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = guard
$NPCTalkKit.say(line)
# Or name them by id, from the collections this node carries. An option is a
# selection, never a description: say() cannot build a speaker or restyle a box.
func interrupt_by_name() -> void:
$NPCTalkKit.say("[color=#ff6b6b]Stop right there![/color]",
{}, {"speaker": &"guard", "dialogue_box": &"warning"})
# The key is what counts, not the value. Leaving "speaker" out falls through to
# the Quick Say default; passing it as null says there is no speaker at all.
func narrate(text: String) -> void:
$NPCTalkKit.say(text, {}, {"speaker": null})
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [_instant_box()]
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
var haru := make_a_speaker()
var guard := TalkSpeaker.new()
guard.speaker_id = &"guard"
guard.display_name = "Guard"
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
var box: TalkBoxTemplate = talk.dialogue_boxes[0]
talk.speakers = [haru, guard, player]
talk.quick_say_speaker = haru
talk.quick_say_dialogue_box = box
var gate := a_conversation_with_three_people(haru, guard, player, box)
talk.conversations = [gate, a_sign_nobody_speaks(box)]
talk.play(&"gate")
for expected in ["Haru", "Guard", "Player", "Haru"]:
if talk._box.get_name_label().text != expected:
failures.append("speakers: expected %s, got '%s'"
% [expected, talk._box.get_name_label().text])
talk.advance()
if talk.is_running():
failures.append("speakers: the sequence should have finished")
# A named face, and a name that does not exist.
add_an_angry_face(haru, PlaceholderTexture2D.new())
shout_this_line(gate.lines[0])
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.variants[&"angry"]:
failures.append("speakers: a named variant should be chosen")
gate.lines[0].visual_variant = &"nonexistent"
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.visual:
failures.append("speakers: an unknown variant should fall back, not blank out")
gate.lines[0].visual_variant = &""
# Nobody speaking.
talk.play(&"sign")
if talk.resolve_speaker(talk.conversations[1].lines[0]) != null:
failures.append("speakers: a sign must resolve to no speaker")
if talk._box.get_name_label().visible:
failures.append("speakers: and show no name row")
talk.stop()
# Reusing game art, with no reference to any node.
var frames := SpriteFrames.new()
frames.add_animation(&"idle")
frames.add_frame(&"idle", PlaceholderTexture2D.new())
var reused := reuse_the_game_art(frames)
if reused.visual.frame_texture(0.0) == null:
failures.append("speakers: an animated visual must resolve a frame")
# say(), with nothing authored, falling through to the Quick Say defaults.
var blank := NPCTalkKit.new()
var quick := _instant_box()
blank.dialogue_boxes = [quick]
blank.speakers = [haru]
blank.quick_say_speaker = haru
blank.quick_say_dialogue_box = quick
add_child(blank)
if not blank.say("Just this."):
failures.append("speakers: say() must work on a node with no conversations")
if blank._box.get_name_label().text != "Haru":
failures.append("speakers: say() should fall through to the Quick Say speaker")
# ...and an explicit option beating it, including "explicitly nobody".
blank.stop()
blank.say("Three days later…", {}, {"speaker": null})
if not blank._box.get_name_label().text.is_empty():
failures.append("speakers: an explicit null speaker must mean narrator")
blank.stop()
blank.queue_free()
return failures
## Typing speed lives on the dialogue box, so a scripted run hands the node a
## box that types instantly.
func _instant_box() -> TalkBoxTemplate:
var template := TalkBoxTemplate.new()
template.typewriter_speed = 0.0
return templateThat is why a narrator, a radio voice, a sign, or a character standing in a level that is not even loaded are all ordinary speakers. There is nothing to point them at.
TalkKit does not control your characters
It never maps a speaker onto a node, never plays a talk or idle animation, never reads gameplay state. If you want the character to animate while it talks, your game says so:
$NPCTalkKit.play(&"greeting")
$AnimationPlayer.play("talk")That one line is clearer than any amount of configuration, and it leaves your animation code where you can see it.
Dialogue visuals
A dialogue visual is how a speaker looks inside the box: a portrait, a bust, or animated SpriteFrames.
Separate from the node is not the same as separate from the asset. Pointing a speaker at the very SpriteFrames your player already uses is the intended way to make the player a speaker:
extends Node
## Docs: /guide/speakers — who is talking, how they look, how they sound.
func _ready() -> void:
_build()
#region speaker
# A speaker is a dialogue identity. No NodePath, no AnimationPlayer, no world
# sprite — so a narrator, a radio voice, or someone standing in a level that is
# not even loaded are all ordinary speakers.
func make_a_speaker() -> TalkSpeaker:
var haru := TalkSpeaker.new()
haru.speaker_id = &"haru"
haru.display_name = "Haru"
haru.visual = TalkDialogueVisual.new()
haru.visual.texture = preload("res://addons/npc_talkkit/icons/talk_speaker.svg")
haru.voice_blip = AudioStreamWAV.new()
return haru
#endregion
#region reuse
# Separate from the node is not the same as separate from the asset. Pointing a
# speaker at the very SpriteFrames the player already uses is the intended way
# to make the player a speaker — TalkKit never looks for the player node.
func reuse_the_game_art(frames: SpriteFrames) -> TalkSpeaker:
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
player.visual = TalkDialogueVisual.new()
player.visual.sprite_frames = frames
player.visual.animation = &"idle"
return player
#endregion
#region variants
# Named faces, not a free-text mood. A variant the speaker does not have falls
# back to their default face rather than drawing nothing.
func add_an_angry_face(haru: TalkSpeaker, angry: Texture2D) -> void:
var variant := TalkDialogueVisual.new()
variant.texture = angry
haru.variants[&"angry"] = variant
func shout_this_line(line: TalkLine) -> void:
line.visual_variant = &"angry"
#endregion
#region multi
# One conversation, several speakers, one straight sequence. The conversation
# says once who normally speaks; a line says so only when it is the exception,
# and the line after it goes back to the default without being told to.
func a_conversation_with_three_people(
haru: TalkSpeaker, guard: TalkSpeaker, player: TalkSpeaker, box: TalkBoxTemplate,
) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"gate"
conversation.default_speaker = haru
conversation.default_dialogue_box = box
for pair in [[null, "Who are you?"], [guard, "Step away."],
[player, "I am only passing through."], [null, "Let him go."]]:
var line := TalkLine.new()
if pair[0] != null:
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = pair[0]
line.text = pair[1]
conversation.lines.append(line)
return conversation
#endregion
#region nobody
# No speaker at all is a working configuration, not a gap. The text still
# types, events still fire; the name row and the visual just stay empty.
func a_sign_nobody_speaks(box: TalkBoxTemplate) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"sign"
conversation.default_speaker = null # nobody, and that is the point
conversation.default_dialogue_box = box
var line := TalkLine.new()
line.text = "[i]The gate is barred.[/i]"
conversation.lines = [line]
return conversation
#endregion
#region say
# Nothing has to be authored anywhere. say() takes a string, a TalkLine, or a
# sequence of them, and runs them through the pipeline play() uses — same box,
# same placement, same events.
func bark(text: String) -> void:
$NPCTalkKit.say(text)
func interrupt(guard: TalkSpeaker) -> void:
var line := TalkLine.new()
line.text = "[color=#ff6b6b]Stop right there![/color]"
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = guard
$NPCTalkKit.say(line)
# Or name them by id, from the collections this node carries. An option is a
# selection, never a description: say() cannot build a speaker or restyle a box.
func interrupt_by_name() -> void:
$NPCTalkKit.say("[color=#ff6b6b]Stop right there![/color]",
{}, {"speaker": &"guard", "dialogue_box": &"warning"})
# The key is what counts, not the value. Leaving "speaker" out falls through to
# the Quick Say default; passing it as null says there is no speaker at all.
func narrate(text: String) -> void:
$NPCTalkKit.say(text, {}, {"speaker": null})
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [_instant_box()]
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
var haru := make_a_speaker()
var guard := TalkSpeaker.new()
guard.speaker_id = &"guard"
guard.display_name = "Guard"
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
var box: TalkBoxTemplate = talk.dialogue_boxes[0]
talk.speakers = [haru, guard, player]
talk.quick_say_speaker = haru
talk.quick_say_dialogue_box = box
var gate := a_conversation_with_three_people(haru, guard, player, box)
talk.conversations = [gate, a_sign_nobody_speaks(box)]
talk.play(&"gate")
for expected in ["Haru", "Guard", "Player", "Haru"]:
if talk._box.get_name_label().text != expected:
failures.append("speakers: expected %s, got '%s'"
% [expected, talk._box.get_name_label().text])
talk.advance()
if talk.is_running():
failures.append("speakers: the sequence should have finished")
# A named face, and a name that does not exist.
add_an_angry_face(haru, PlaceholderTexture2D.new())
shout_this_line(gate.lines[0])
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.variants[&"angry"]:
failures.append("speakers: a named variant should be chosen")
gate.lines[0].visual_variant = &"nonexistent"
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.visual:
failures.append("speakers: an unknown variant should fall back, not blank out")
gate.lines[0].visual_variant = &""
# Nobody speaking.
talk.play(&"sign")
if talk.resolve_speaker(talk.conversations[1].lines[0]) != null:
failures.append("speakers: a sign must resolve to no speaker")
if talk._box.get_name_label().visible:
failures.append("speakers: and show no name row")
talk.stop()
# Reusing game art, with no reference to any node.
var frames := SpriteFrames.new()
frames.add_animation(&"idle")
frames.add_frame(&"idle", PlaceholderTexture2D.new())
var reused := reuse_the_game_art(frames)
if reused.visual.frame_texture(0.0) == null:
failures.append("speakers: an animated visual must resolve a frame")
# say(), with nothing authored, falling through to the Quick Say defaults.
var blank := NPCTalkKit.new()
var quick := _instant_box()
blank.dialogue_boxes = [quick]
blank.speakers = [haru]
blank.quick_say_speaker = haru
blank.quick_say_dialogue_box = quick
add_child(blank)
if not blank.say("Just this."):
failures.append("speakers: say() must work on a node with no conversations")
if blank._box.get_name_label().text != "Haru":
failures.append("speakers: say() should fall through to the Quick Say speaker")
# ...and an explicit option beating it, including "explicitly nobody".
blank.stop()
blank.say("Three days later…", {}, {"speaker": null})
if not blank._box.get_name_label().text.is_empty():
failures.append("speakers: an explicit null speaker must mean narrator")
blank.stop()
blank.queue_free()
return failures
## Typing speed lives on the dialogue box, so a scripted run hands the node a
## box that types instantly.
func _instant_box() -> TalkBoxTemplate:
var template := TalkBoxTemplate.new()
template.typewriter_speed = 0.0
return templateTalkKit never goes looking for $Player/AnimatedSprite2D and never copies its runtime state. It reads the resource you handed it, which is why this works for a character who is nowhere in the scene.
Named faces
A speaker may carry named alternatives, and a line picks one:
extends Node
## Docs: /guide/speakers — who is talking, how they look, how they sound.
func _ready() -> void:
_build()
#region speaker
# A speaker is a dialogue identity. No NodePath, no AnimationPlayer, no world
# sprite — so a narrator, a radio voice, or someone standing in a level that is
# not even loaded are all ordinary speakers.
func make_a_speaker() -> TalkSpeaker:
var haru := TalkSpeaker.new()
haru.speaker_id = &"haru"
haru.display_name = "Haru"
haru.visual = TalkDialogueVisual.new()
haru.visual.texture = preload("res://addons/npc_talkkit/icons/talk_speaker.svg")
haru.voice_blip = AudioStreamWAV.new()
return haru
#endregion
#region reuse
# Separate from the node is not the same as separate from the asset. Pointing a
# speaker at the very SpriteFrames the player already uses is the intended way
# to make the player a speaker — TalkKit never looks for the player node.
func reuse_the_game_art(frames: SpriteFrames) -> TalkSpeaker:
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
player.visual = TalkDialogueVisual.new()
player.visual.sprite_frames = frames
player.visual.animation = &"idle"
return player
#endregion
#region variants
# Named faces, not a free-text mood. A variant the speaker does not have falls
# back to their default face rather than drawing nothing.
func add_an_angry_face(haru: TalkSpeaker, angry: Texture2D) -> void:
var variant := TalkDialogueVisual.new()
variant.texture = angry
haru.variants[&"angry"] = variant
func shout_this_line(line: TalkLine) -> void:
line.visual_variant = &"angry"
#endregion
#region multi
# One conversation, several speakers, one straight sequence. The conversation
# says once who normally speaks; a line says so only when it is the exception,
# and the line after it goes back to the default without being told to.
func a_conversation_with_three_people(
haru: TalkSpeaker, guard: TalkSpeaker, player: TalkSpeaker, box: TalkBoxTemplate,
) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"gate"
conversation.default_speaker = haru
conversation.default_dialogue_box = box
for pair in [[null, "Who are you?"], [guard, "Step away."],
[player, "I am only passing through."], [null, "Let him go."]]:
var line := TalkLine.new()
if pair[0] != null:
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = pair[0]
line.text = pair[1]
conversation.lines.append(line)
return conversation
#endregion
#region nobody
# No speaker at all is a working configuration, not a gap. The text still
# types, events still fire; the name row and the visual just stay empty.
func a_sign_nobody_speaks(box: TalkBoxTemplate) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"sign"
conversation.default_speaker = null # nobody, and that is the point
conversation.default_dialogue_box = box
var line := TalkLine.new()
line.text = "[i]The gate is barred.[/i]"
conversation.lines = [line]
return conversation
#endregion
#region say
# Nothing has to be authored anywhere. say() takes a string, a TalkLine, or a
# sequence of them, and runs them through the pipeline play() uses — same box,
# same placement, same events.
func bark(text: String) -> void:
$NPCTalkKit.say(text)
func interrupt(guard: TalkSpeaker) -> void:
var line := TalkLine.new()
line.text = "[color=#ff6b6b]Stop right there![/color]"
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = guard
$NPCTalkKit.say(line)
# Or name them by id, from the collections this node carries. An option is a
# selection, never a description: say() cannot build a speaker or restyle a box.
func interrupt_by_name() -> void:
$NPCTalkKit.say("[color=#ff6b6b]Stop right there![/color]",
{}, {"speaker": &"guard", "dialogue_box": &"warning"})
# The key is what counts, not the value. Leaving "speaker" out falls through to
# the Quick Say default; passing it as null says there is no speaker at all.
func narrate(text: String) -> void:
$NPCTalkKit.say(text, {}, {"speaker": null})
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [_instant_box()]
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
var haru := make_a_speaker()
var guard := TalkSpeaker.new()
guard.speaker_id = &"guard"
guard.display_name = "Guard"
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
var box: TalkBoxTemplate = talk.dialogue_boxes[0]
talk.speakers = [haru, guard, player]
talk.quick_say_speaker = haru
talk.quick_say_dialogue_box = box
var gate := a_conversation_with_three_people(haru, guard, player, box)
talk.conversations = [gate, a_sign_nobody_speaks(box)]
talk.play(&"gate")
for expected in ["Haru", "Guard", "Player", "Haru"]:
if talk._box.get_name_label().text != expected:
failures.append("speakers: expected %s, got '%s'"
% [expected, talk._box.get_name_label().text])
talk.advance()
if talk.is_running():
failures.append("speakers: the sequence should have finished")
# A named face, and a name that does not exist.
add_an_angry_face(haru, PlaceholderTexture2D.new())
shout_this_line(gate.lines[0])
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.variants[&"angry"]:
failures.append("speakers: a named variant should be chosen")
gate.lines[0].visual_variant = &"nonexistent"
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.visual:
failures.append("speakers: an unknown variant should fall back, not blank out")
gate.lines[0].visual_variant = &""
# Nobody speaking.
talk.play(&"sign")
if talk.resolve_speaker(talk.conversations[1].lines[0]) != null:
failures.append("speakers: a sign must resolve to no speaker")
if talk._box.get_name_label().visible:
failures.append("speakers: and show no name row")
talk.stop()
# Reusing game art, with no reference to any node.
var frames := SpriteFrames.new()
frames.add_animation(&"idle")
frames.add_frame(&"idle", PlaceholderTexture2D.new())
var reused := reuse_the_game_art(frames)
if reused.visual.frame_texture(0.0) == null:
failures.append("speakers: an animated visual must resolve a frame")
# say(), with nothing authored, falling through to the Quick Say defaults.
var blank := NPCTalkKit.new()
var quick := _instant_box()
blank.dialogue_boxes = [quick]
blank.speakers = [haru]
blank.quick_say_speaker = haru
blank.quick_say_dialogue_box = quick
add_child(blank)
if not blank.say("Just this."):
failures.append("speakers: say() must work on a node with no conversations")
if blank._box.get_name_label().text != "Haru":
failures.append("speakers: say() should fall through to the Quick Say speaker")
# ...and an explicit option beating it, including "explicitly nobody".
blank.stop()
blank.say("Three days later…", {}, {"speaker": null})
if not blank._box.get_name_label().text.is_empty():
failures.append("speakers: an explicit null speaker must mean narrator")
blank.stop()
blank.queue_free()
return failures
## Typing speed lives on the dialogue box, so a scripted run hands the node a
## box that types instantly.
func _instant_box() -> TalkBoxTemplate:
var template := TalkBoxTemplate.new()
template.typewriter_speed = 0.0
return templateA variant the speaker does not have falls back to their default face rather than drawing nothing, so a line written for an angry Haru still plays against a Haru who only has one face.
Why not "emotion"?
Because a free-text mood that resolves to nothing is a field that looks alive and is not — the addon shipped one of those once. A variant name resolves to an actual visual or it falls back. There is no third outcome.
Several speakers, one conversation
A conversation says once who normally speaks. A line says so only when it is the exception — and the line after it goes back to the default without being told to.
extends Node
## Docs: /guide/speakers — who is talking, how they look, how they sound.
func _ready() -> void:
_build()
#region speaker
# A speaker is a dialogue identity. No NodePath, no AnimationPlayer, no world
# sprite — so a narrator, a radio voice, or someone standing in a level that is
# not even loaded are all ordinary speakers.
func make_a_speaker() -> TalkSpeaker:
var haru := TalkSpeaker.new()
haru.speaker_id = &"haru"
haru.display_name = "Haru"
haru.visual = TalkDialogueVisual.new()
haru.visual.texture = preload("res://addons/npc_talkkit/icons/talk_speaker.svg")
haru.voice_blip = AudioStreamWAV.new()
return haru
#endregion
#region reuse
# Separate from the node is not the same as separate from the asset. Pointing a
# speaker at the very SpriteFrames the player already uses is the intended way
# to make the player a speaker — TalkKit never looks for the player node.
func reuse_the_game_art(frames: SpriteFrames) -> TalkSpeaker:
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
player.visual = TalkDialogueVisual.new()
player.visual.sprite_frames = frames
player.visual.animation = &"idle"
return player
#endregion
#region variants
# Named faces, not a free-text mood. A variant the speaker does not have falls
# back to their default face rather than drawing nothing.
func add_an_angry_face(haru: TalkSpeaker, angry: Texture2D) -> void:
var variant := TalkDialogueVisual.new()
variant.texture = angry
haru.variants[&"angry"] = variant
func shout_this_line(line: TalkLine) -> void:
line.visual_variant = &"angry"
#endregion
#region multi
# One conversation, several speakers, one straight sequence. The conversation
# says once who normally speaks; a line says so only when it is the exception,
# and the line after it goes back to the default without being told to.
func a_conversation_with_three_people(
haru: TalkSpeaker, guard: TalkSpeaker, player: TalkSpeaker, box: TalkBoxTemplate,
) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"gate"
conversation.default_speaker = haru
conversation.default_dialogue_box = box
for pair in [[null, "Who are you?"], [guard, "Step away."],
[player, "I am only passing through."], [null, "Let him go."]]:
var line := TalkLine.new()
if pair[0] != null:
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = pair[0]
line.text = pair[1]
conversation.lines.append(line)
return conversation
#endregion
#region nobody
# No speaker at all is a working configuration, not a gap. The text still
# types, events still fire; the name row and the visual just stay empty.
func a_sign_nobody_speaks(box: TalkBoxTemplate) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"sign"
conversation.default_speaker = null # nobody, and that is the point
conversation.default_dialogue_box = box
var line := TalkLine.new()
line.text = "[i]The gate is barred.[/i]"
conversation.lines = [line]
return conversation
#endregion
#region say
# Nothing has to be authored anywhere. say() takes a string, a TalkLine, or a
# sequence of them, and runs them through the pipeline play() uses — same box,
# same placement, same events.
func bark(text: String) -> void:
$NPCTalkKit.say(text)
func interrupt(guard: TalkSpeaker) -> void:
var line := TalkLine.new()
line.text = "[color=#ff6b6b]Stop right there![/color]"
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = guard
$NPCTalkKit.say(line)
# Or name them by id, from the collections this node carries. An option is a
# selection, never a description: say() cannot build a speaker or restyle a box.
func interrupt_by_name() -> void:
$NPCTalkKit.say("[color=#ff6b6b]Stop right there![/color]",
{}, {"speaker": &"guard", "dialogue_box": &"warning"})
# The key is what counts, not the value. Leaving "speaker" out falls through to
# the Quick Say default; passing it as null says there is no speaker at all.
func narrate(text: String) -> void:
$NPCTalkKit.say(text, {}, {"speaker": null})
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [_instant_box()]
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
var haru := make_a_speaker()
var guard := TalkSpeaker.new()
guard.speaker_id = &"guard"
guard.display_name = "Guard"
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
var box: TalkBoxTemplate = talk.dialogue_boxes[0]
talk.speakers = [haru, guard, player]
talk.quick_say_speaker = haru
talk.quick_say_dialogue_box = box
var gate := a_conversation_with_three_people(haru, guard, player, box)
talk.conversations = [gate, a_sign_nobody_speaks(box)]
talk.play(&"gate")
for expected in ["Haru", "Guard", "Player", "Haru"]:
if talk._box.get_name_label().text != expected:
failures.append("speakers: expected %s, got '%s'"
% [expected, talk._box.get_name_label().text])
talk.advance()
if talk.is_running():
failures.append("speakers: the sequence should have finished")
# A named face, and a name that does not exist.
add_an_angry_face(haru, PlaceholderTexture2D.new())
shout_this_line(gate.lines[0])
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.variants[&"angry"]:
failures.append("speakers: a named variant should be chosen")
gate.lines[0].visual_variant = &"nonexistent"
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.visual:
failures.append("speakers: an unknown variant should fall back, not blank out")
gate.lines[0].visual_variant = &""
# Nobody speaking.
talk.play(&"sign")
if talk.resolve_speaker(talk.conversations[1].lines[0]) != null:
failures.append("speakers: a sign must resolve to no speaker")
if talk._box.get_name_label().visible:
failures.append("speakers: and show no name row")
talk.stop()
# Reusing game art, with no reference to any node.
var frames := SpriteFrames.new()
frames.add_animation(&"idle")
frames.add_frame(&"idle", PlaceholderTexture2D.new())
var reused := reuse_the_game_art(frames)
if reused.visual.frame_texture(0.0) == null:
failures.append("speakers: an animated visual must resolve a frame")
# say(), with nothing authored, falling through to the Quick Say defaults.
var blank := NPCTalkKit.new()
var quick := _instant_box()
blank.dialogue_boxes = [quick]
blank.speakers = [haru]
blank.quick_say_speaker = haru
blank.quick_say_dialogue_box = quick
add_child(blank)
if not blank.say("Just this."):
failures.append("speakers: say() must work on a node with no conversations")
if blank._box.get_name_label().text != "Haru":
failures.append("speakers: say() should fall through to the Quick Say speaker")
# ...and an explicit option beating it, including "explicitly nobody".
blank.stop()
blank.say("Three days later…", {}, {"speaker": null})
if not blank._box.get_name_label().text.is_empty():
failures.append("speakers: an explicit null speaker must mean narrator")
blank.stop()
blank.queue_free()
return failures
## Typing speed lives on the dialogue box, so a scripted run hands the node a
## box that types instantly.
func _instant_box() -> TalkBoxTemplate:
var template := TalkBoxTemplate.new()
template.typewriter_speed = 0.0
return templateThere is no cast to declare and no roster to set up. Multi-speaker is not a mode — it is what happens when a line names someone else.
Two levels, and only two
line override → conversation defaultThat is the whole resolution model, for the speaker, the box and the placement alike. The node underneath holds a collection to pick from; it is not a third tier that decides what a conversation forgot to say.
In the editor
The TalkKit workspace shows conversation defaults once above the lines. Each row stays text-first and shows the resolved speaker as a compact identity:
Defaults Haru Classic Panel Screen · Bottom
1 H Haru Defaults The road is dangerous.
2 H Haru Defaults Stay close.
3 R Ren 2 overrides Wait!
4 H Haru Defaults What is it?You can read the exception in one pass without repeating inherited fields on every row. Select line 3 to see Speaker, Dialogue Box and other exceptions in the detail pane; inherited choices name their effective value.
Each dropdown offers what the node carries, plus the answers that are not a resource:
| Default (Haru) | inherit from the conversation |
| None · narrator | nobody speaks this line |
| Haru, Ren, Player… | the node's speakers |
This is still not branching
One conversation is one straight sequence. Several speakers change the name on the box; they do not change what runs next. Branching stays where it was: your game answers a blocking event and calls play() again.
Choosing, not editing
A conversation and a line only ever select a speaker or a box. Neither edits one. Creating, duplicating, sharing and deleting happen in the workspace's Speakers and Dialogue Boxes sections. Each manager has a list, focused editor, live runtime preview, scope and usage. The Inspector only summarizes these collections and opens the workspace.
A line's Overrides therefore pick and nothing more. In the line's detail pane, Speaker is a dropdown of Inherit · Haru (the conversation default), None · narrator, and the node's own speakers; Dialogue Box is Inherit plus the node's own boxes. To use one the node does not carry yet, add it in the manager first. The conversation's Defaults row is the one place with Browse…, for a speaker or box saved anywhere in the project.
Picking Ren for one line is local; Ren is not. Editing Ren in the Speakers manager changes every line and every NPC that points at that resource — the manager's usage list says which.
That split is the rule, not a convenience: if the same box could be fully edited from the manager and from a line, the second screen would be a copy of the first that quietly drifts from it. Want a red variant of the Speech Bubble? Duplicate it in the manager, rename it, and pick it on the line.
A speaker made in the manager needs no speaker_id: whatever points at it points at the resource. An id is a filing key, and is only required once the speaker goes into a shared library, where other content addresses it by name.
Nobody speaking
No speaker at all is a working configuration, not a gap:
extends Node
## Docs: /guide/speakers — who is talking, how they look, how they sound.
func _ready() -> void:
_build()
#region speaker
# A speaker is a dialogue identity. No NodePath, no AnimationPlayer, no world
# sprite — so a narrator, a radio voice, or someone standing in a level that is
# not even loaded are all ordinary speakers.
func make_a_speaker() -> TalkSpeaker:
var haru := TalkSpeaker.new()
haru.speaker_id = &"haru"
haru.display_name = "Haru"
haru.visual = TalkDialogueVisual.new()
haru.visual.texture = preload("res://addons/npc_talkkit/icons/talk_speaker.svg")
haru.voice_blip = AudioStreamWAV.new()
return haru
#endregion
#region reuse
# Separate from the node is not the same as separate from the asset. Pointing a
# speaker at the very SpriteFrames the player already uses is the intended way
# to make the player a speaker — TalkKit never looks for the player node.
func reuse_the_game_art(frames: SpriteFrames) -> TalkSpeaker:
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
player.visual = TalkDialogueVisual.new()
player.visual.sprite_frames = frames
player.visual.animation = &"idle"
return player
#endregion
#region variants
# Named faces, not a free-text mood. A variant the speaker does not have falls
# back to their default face rather than drawing nothing.
func add_an_angry_face(haru: TalkSpeaker, angry: Texture2D) -> void:
var variant := TalkDialogueVisual.new()
variant.texture = angry
haru.variants[&"angry"] = variant
func shout_this_line(line: TalkLine) -> void:
line.visual_variant = &"angry"
#endregion
#region multi
# One conversation, several speakers, one straight sequence. The conversation
# says once who normally speaks; a line says so only when it is the exception,
# and the line after it goes back to the default without being told to.
func a_conversation_with_three_people(
haru: TalkSpeaker, guard: TalkSpeaker, player: TalkSpeaker, box: TalkBoxTemplate,
) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"gate"
conversation.default_speaker = haru
conversation.default_dialogue_box = box
for pair in [[null, "Who are you?"], [guard, "Step away."],
[player, "I am only passing through."], [null, "Let him go."]]:
var line := TalkLine.new()
if pair[0] != null:
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = pair[0]
line.text = pair[1]
conversation.lines.append(line)
return conversation
#endregion
#region nobody
# No speaker at all is a working configuration, not a gap. The text still
# types, events still fire; the name row and the visual just stay empty.
func a_sign_nobody_speaks(box: TalkBoxTemplate) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"sign"
conversation.default_speaker = null # nobody, and that is the point
conversation.default_dialogue_box = box
var line := TalkLine.new()
line.text = "[i]The gate is barred.[/i]"
conversation.lines = [line]
return conversation
#endregion
#region say
# Nothing has to be authored anywhere. say() takes a string, a TalkLine, or a
# sequence of them, and runs them through the pipeline play() uses — same box,
# same placement, same events.
func bark(text: String) -> void:
$NPCTalkKit.say(text)
func interrupt(guard: TalkSpeaker) -> void:
var line := TalkLine.new()
line.text = "[color=#ff6b6b]Stop right there![/color]"
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = guard
$NPCTalkKit.say(line)
# Or name them by id, from the collections this node carries. An option is a
# selection, never a description: say() cannot build a speaker or restyle a box.
func interrupt_by_name() -> void:
$NPCTalkKit.say("[color=#ff6b6b]Stop right there![/color]",
{}, {"speaker": &"guard", "dialogue_box": &"warning"})
# The key is what counts, not the value. Leaving "speaker" out falls through to
# the Quick Say default; passing it as null says there is no speaker at all.
func narrate(text: String) -> void:
$NPCTalkKit.say(text, {}, {"speaker": null})
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [_instant_box()]
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
var haru := make_a_speaker()
var guard := TalkSpeaker.new()
guard.speaker_id = &"guard"
guard.display_name = "Guard"
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
var box: TalkBoxTemplate = talk.dialogue_boxes[0]
talk.speakers = [haru, guard, player]
talk.quick_say_speaker = haru
talk.quick_say_dialogue_box = box
var gate := a_conversation_with_three_people(haru, guard, player, box)
talk.conversations = [gate, a_sign_nobody_speaks(box)]
talk.play(&"gate")
for expected in ["Haru", "Guard", "Player", "Haru"]:
if talk._box.get_name_label().text != expected:
failures.append("speakers: expected %s, got '%s'"
% [expected, talk._box.get_name_label().text])
talk.advance()
if talk.is_running():
failures.append("speakers: the sequence should have finished")
# A named face, and a name that does not exist.
add_an_angry_face(haru, PlaceholderTexture2D.new())
shout_this_line(gate.lines[0])
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.variants[&"angry"]:
failures.append("speakers: a named variant should be chosen")
gate.lines[0].visual_variant = &"nonexistent"
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.visual:
failures.append("speakers: an unknown variant should fall back, not blank out")
gate.lines[0].visual_variant = &""
# Nobody speaking.
talk.play(&"sign")
if talk.resolve_speaker(talk.conversations[1].lines[0]) != null:
failures.append("speakers: a sign must resolve to no speaker")
if talk._box.get_name_label().visible:
failures.append("speakers: and show no name row")
talk.stop()
# Reusing game art, with no reference to any node.
var frames := SpriteFrames.new()
frames.add_animation(&"idle")
frames.add_frame(&"idle", PlaceholderTexture2D.new())
var reused := reuse_the_game_art(frames)
if reused.visual.frame_texture(0.0) == null:
failures.append("speakers: an animated visual must resolve a frame")
# say(), with nothing authored, falling through to the Quick Say defaults.
var blank := NPCTalkKit.new()
var quick := _instant_box()
blank.dialogue_boxes = [quick]
blank.speakers = [haru]
blank.quick_say_speaker = haru
blank.quick_say_dialogue_box = quick
add_child(blank)
if not blank.say("Just this."):
failures.append("speakers: say() must work on a node with no conversations")
if blank._box.get_name_label().text != "Haru":
failures.append("speakers: say() should fall through to the Quick Say speaker")
# ...and an explicit option beating it, including "explicitly nobody".
blank.stop()
blank.say("Three days later…", {}, {"speaker": null})
if not blank._box.get_name_label().text.is_empty():
failures.append("speakers: an explicit null speaker must mean narrator")
blank.stop()
blank.queue_free()
return failures
## Typing speed lives on the dialogue box, so a scripted run hands the node a
## box that types instantly.
func _instant_box() -> TalkBoxTemplate:
var template := TalkBoxTemplate.new()
template.typewriter_speed = 0.0
return templateThe text still types, events still fire. The name row and the visual simply stay empty — which is exactly what a sign, a narrator, or a system message should look like.
Text with nothing authored
say() is the other way content reaches the box. It takes a string, a TalkLine, or a sequence of them:
extends Node
## Docs: /guide/speakers — who is talking, how they look, how they sound.
func _ready() -> void:
_build()
#region speaker
# A speaker is a dialogue identity. No NodePath, no AnimationPlayer, no world
# sprite — so a narrator, a radio voice, or someone standing in a level that is
# not even loaded are all ordinary speakers.
func make_a_speaker() -> TalkSpeaker:
var haru := TalkSpeaker.new()
haru.speaker_id = &"haru"
haru.display_name = "Haru"
haru.visual = TalkDialogueVisual.new()
haru.visual.texture = preload("res://addons/npc_talkkit/icons/talk_speaker.svg")
haru.voice_blip = AudioStreamWAV.new()
return haru
#endregion
#region reuse
# Separate from the node is not the same as separate from the asset. Pointing a
# speaker at the very SpriteFrames the player already uses is the intended way
# to make the player a speaker — TalkKit never looks for the player node.
func reuse_the_game_art(frames: SpriteFrames) -> TalkSpeaker:
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
player.visual = TalkDialogueVisual.new()
player.visual.sprite_frames = frames
player.visual.animation = &"idle"
return player
#endregion
#region variants
# Named faces, not a free-text mood. A variant the speaker does not have falls
# back to their default face rather than drawing nothing.
func add_an_angry_face(haru: TalkSpeaker, angry: Texture2D) -> void:
var variant := TalkDialogueVisual.new()
variant.texture = angry
haru.variants[&"angry"] = variant
func shout_this_line(line: TalkLine) -> void:
line.visual_variant = &"angry"
#endregion
#region multi
# One conversation, several speakers, one straight sequence. The conversation
# says once who normally speaks; a line says so only when it is the exception,
# and the line after it goes back to the default without being told to.
func a_conversation_with_three_people(
haru: TalkSpeaker, guard: TalkSpeaker, player: TalkSpeaker, box: TalkBoxTemplate,
) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"gate"
conversation.default_speaker = haru
conversation.default_dialogue_box = box
for pair in [[null, "Who are you?"], [guard, "Step away."],
[player, "I am only passing through."], [null, "Let him go."]]:
var line := TalkLine.new()
if pair[0] != null:
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = pair[0]
line.text = pair[1]
conversation.lines.append(line)
return conversation
#endregion
#region nobody
# No speaker at all is a working configuration, not a gap. The text still
# types, events still fire; the name row and the visual just stay empty.
func a_sign_nobody_speaks(box: TalkBoxTemplate) -> TalkConversation:
var conversation := TalkConversation.new()
conversation.conversation_id = &"sign"
conversation.default_speaker = null # nobody, and that is the point
conversation.default_dialogue_box = box
var line := TalkLine.new()
line.text = "[i]The gate is barred.[/i]"
conversation.lines = [line]
return conversation
#endregion
#region say
# Nothing has to be authored anywhere. say() takes a string, a TalkLine, or a
# sequence of them, and runs them through the pipeline play() uses — same box,
# same placement, same events.
func bark(text: String) -> void:
$NPCTalkKit.say(text)
func interrupt(guard: TalkSpeaker) -> void:
var line := TalkLine.new()
line.text = "[color=#ff6b6b]Stop right there![/color]"
line.speaker_mode = TalkLine.SpeakerMode.SPEAKER
line.speaker = guard
$NPCTalkKit.say(line)
# Or name them by id, from the collections this node carries. An option is a
# selection, never a description: say() cannot build a speaker or restyle a box.
func interrupt_by_name() -> void:
$NPCTalkKit.say("[color=#ff6b6b]Stop right there![/color]",
{}, {"speaker": &"guard", "dialogue_box": &"warning"})
# The key is what counts, not the value. Leaving "speaker" out falls through to
# the Quick Say default; passing it as null says there is no speaker at all.
func narrate(text: String) -> void:
$NPCTalkKit.say(text, {}, {"speaker": null})
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [_instant_box()]
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
var haru := make_a_speaker()
var guard := TalkSpeaker.new()
guard.speaker_id = &"guard"
guard.display_name = "Guard"
var player := TalkSpeaker.new()
player.speaker_id = &"player"
player.display_name = "Player"
var box: TalkBoxTemplate = talk.dialogue_boxes[0]
talk.speakers = [haru, guard, player]
talk.quick_say_speaker = haru
talk.quick_say_dialogue_box = box
var gate := a_conversation_with_three_people(haru, guard, player, box)
talk.conversations = [gate, a_sign_nobody_speaks(box)]
talk.play(&"gate")
for expected in ["Haru", "Guard", "Player", "Haru"]:
if talk._box.get_name_label().text != expected:
failures.append("speakers: expected %s, got '%s'"
% [expected, talk._box.get_name_label().text])
talk.advance()
if talk.is_running():
failures.append("speakers: the sequence should have finished")
# A named face, and a name that does not exist.
add_an_angry_face(haru, PlaceholderTexture2D.new())
shout_this_line(gate.lines[0])
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.variants[&"angry"]:
failures.append("speakers: a named variant should be chosen")
gate.lines[0].visual_variant = &"nonexistent"
if talk.resolve_dialogue_visual(haru, gate.lines[0]) != haru.visual:
failures.append("speakers: an unknown variant should fall back, not blank out")
gate.lines[0].visual_variant = &""
# Nobody speaking.
talk.play(&"sign")
if talk.resolve_speaker(talk.conversations[1].lines[0]) != null:
failures.append("speakers: a sign must resolve to no speaker")
if talk._box.get_name_label().visible:
failures.append("speakers: and show no name row")
talk.stop()
# Reusing game art, with no reference to any node.
var frames := SpriteFrames.new()
frames.add_animation(&"idle")
frames.add_frame(&"idle", PlaceholderTexture2D.new())
var reused := reuse_the_game_art(frames)
if reused.visual.frame_texture(0.0) == null:
failures.append("speakers: an animated visual must resolve a frame")
# say(), with nothing authored, falling through to the Quick Say defaults.
var blank := NPCTalkKit.new()
var quick := _instant_box()
blank.dialogue_boxes = [quick]
blank.speakers = [haru]
blank.quick_say_speaker = haru
blank.quick_say_dialogue_box = quick
add_child(blank)
if not blank.say("Just this."):
failures.append("speakers: say() must work on a node with no conversations")
if blank._box.get_name_label().text != "Haru":
failures.append("speakers: say() should fall through to the Quick Say speaker")
# ...and an explicit option beating it, including "explicitly nobody".
blank.stop()
blank.say("Three days later…", {}, {"speaker": null})
if not blank._box.get_name_label().text.is_empty():
failures.append("speakers: an explicit null speaker must mean narrator")
blank.stop()
blank.queue_free()
return failures
## Typing speed lives on the dialogue box, so a scripted run hands the node a
## box that types instantly.
func _instant_box() -> TalkBoxTemplate:
var template := TalkBoxTemplate.new()
template.typewriter_speed = 0.0
return templateA node with zero saved conversations is a working node. say() is not a second playback engine either: the content becomes a conversation that is never saved and runs through the pipeline play() uses, so the box, the placement, the events and the blocking rules are all identical.
Rich text
A line is rich text, in authored conversations and in say() alike:
$NPCTalkKit.say("[color=red][b]Warning![/b][/color] Go back.")The typewriter reveals the words, never the tags — a half-typed line never shows [colo. Godot's own BBCode is the whole vocabulary; TalkKit adds no markup language of its own, because inline story commands belong to a story system, not to a box that draws text.
What goes where
| Speaker | Dialogue Box | |
|---|---|---|
| The name shown | ✅ provides it | ✅ decides whether to show it |
| The face shown | ✅ provides it | ✅ decides size, side, whether to show it |
| Voice blips | ✅ the sound | — |
| Typing speed | — | ✅ |
| Colours, fonts, padding | — | ✅ |
The speaker provides data; the box provides slots and rendering rules. Neither needs to know much about the other, which is why one speaker works in every box you own.