Skip to content

Dialogue Box Anatomy ​

The Anatomy Map: every part of the box labelled, with the eight positions a decoration can sit at

This is the Anatomy Map from the Dialogue Boxes workspace. To see it on your own box, press ◎ Anatomy Map above the preview:

  • Hover a part to learn what it is.
  • Click a part to open its settings.
  • Click a square to add a decoration there.

⛶ Expand opens the larger map shown here.

Content Slots ​

A Content Slot is a part of the box: what is this?

SlotWhat it isSettings group
PanelThe box's main background area.Panel
Speaker NameThe active speaker's name.Speaker Name
Dialogue VisualThe speaker's portrait, avatar or bust.Dialogue Visual
TextThe dialogue line.Text
Continue IndicatorShown when the player can advance.Continue Indicator
TailPoints the box at the speaker when it follows one.Tail

Each slot has one group in the box's settings, and its skin lives in that group. There is no separate "skin" screen to find.

Visual Layers, Depth and Position ​

A Visual Layer is how a slot looks beyond its basic settings: a name box, a portrait frame, a corner ornament, an edge strip, a paper texture. "Layers" here has nothing to do with Godot's CanvasLayer.

  • Layers go on the Panel, the Speaker Name and the Dialogue Visual. Text, Tail and Continue Indicator have their own settings instead.
  • A layer has a Depth: Behind content (paper under the text) or Above content (a gold frame over it). Within a depth, list order decides what is on top.
  • A layer has a Position on its slot: Fill, an edge (Top, Bottom, Left, Right) or a corner (Top Left, Top Right, Bottom Left, Bottom Right).

Position is not Placement

Position is where a decoration sits on the box. Placement is where the whole box sits on screen: following a character, or pinned to a corner of it. The two never mix.

The Panel, a name box, a frame and every layer are all boxes, and they share one Appearance editor: the same four choices and, for the same choice, the same controls. Each draws a normal Godot resource, and the editor names the type:

Visual SourceStored asBest for
Flat StyleStyleBoxFlatsolid fills, borders, rounded corners
Nine-slice TextureStyleBoxTextureresizable frames and pixel panels
ImageTexture2D or AtlasTextureornaments, edges, badges, overlays, a plain image panel
Existing Godot Resourceany StyleBox or Texture2Dwhat your project already has

Flat Style, then a nine-slice frame, then layers on top

Flat dialogue box ​

Before → configuration → result: a new box → Panel › Appearance: Flat Style, pick a background, border and radius → a clean panel. This is the default, and nothing else on this page is needed for it.

gd
extends Node

## Docs: /guide/dialogue-box — what the box is and how it looks.

func _ready() -> void:
	_build()


#region template
# Three templates ship with the addon. None bundle a font or a texture, so
# they inherit whatever font your project already uses.
func use_a_template(talk: NPCTalkKit) -> void:
	# A typed array, because that is what the node carries. Assigning a bare
	# `[...]` through an untyped `$NPCTalkKit` fails at run time, not compile.
	var boxes: Array[TalkBoxTemplate] = [
		preload("res://addons/npc_talkkit/templates/terminal.tres")]
	talk.dialogue_boxes = boxes
#endregion


#region share
# A style is an ordinary Resource. Save one as a .tres, assign it to twenty
# NPCs, edit it once, and all twenty change. Godot's Make Unique lets one of
# them diverge. There is no TalkKit-specific sharing mechanism to learn.
func share_one_template(npcs: Array[NPCTalkKit], template: TalkBoxTemplate) -> void:
	var boxes: Array[TalkBoxTemplate] = [template]
	for npc in npcs:
		npc.dialogue_boxes = boxes
#endregion


#region tweak
# Styles hold appearance only — no target, no NodePath, no per-character data.
# That is what makes sharing safe.
func warm_up_the_box(template: TalkBoxTemplate) -> void:
	# The Panel's Base Appearance is a plain Godot StyleBoxFlat.
	var panel := template.base.style as StyleBoxFlat
	panel.bg_color = Color(0.14, 0.09, 0.06, 0.96)
	panel.border_color = Color(0.82, 0.58, 0.28)
	panel.set_corner_radius_all(18)
	template.text_size = 21
	template.open_transition = "slide"
	template.transition_duration = 0.22
#endregion


#region chain
# Two levels, and only two:
#
#   line.dialogue_box  →  conversation.default_dialogue_box
#
# A conversation says once which box paints it. A line says so only when it is
# the exception — a flashback, an interruption — and the line after it goes
# back to the default without being told to.
func use_another_box_for_one_line(line: TalkLine, flashback: TalkBoxTemplate) -> void:
	line.dialogue_box = flashback


func use_another_box_for_a_conversation(conversation: TalkConversation, warning: TalkBoxTemplate) -> void:
	conversation.default_dialogue_box = warning
#endregion


#region theme
# Already have a UI theme? Assign it and the box stops overriding fonts, sizes
# and text colours, so your theme wins. The Panel, padding and layers always
# apply: a Theme styles the text, never removes the frame.
func follow_the_project_theme(template: TalkBoxTemplate, theme: Theme) -> void:
	template.theme = theme
#endregion


#region nine_slice
# A frame from an asset pack: the Panel's Base Appearance becomes a nine-slice.
# Scale keeps pixel art crisp; padding follows the frame's margins unless set
# to Custom.
func use_a_pixel_frame(template: TalkBoxTemplate, frame: Texture2D) -> void:
	template.base.visual_source = TalkBoxLayer.Source.NINE_SLICE
	var panel := template.base.style as StyleBoxTexture
	panel.texture = frame
	panel.set_texture_margin_all(8)
	template.base.scale = 3
	template.base.filter = CanvasItem.TEXTURE_FILTER_NEAREST
#endregion


#region layers
# Layers decorate the Panel, the Speaker Name and the Dialogue Visual. Each
# draws a native resource: here an AtlasTexture region in a corner, and a
# StyleBoxFlat name box behind the name.
func add_an_ornament_and_a_name_box(template: TalkBoxTemplate, ornaments: Texture2D) -> void:
	var gem := AtlasTexture.new()
	gem.atlas = ornaments
	gem.region = Rect2(0, 0, 16, 16)
	var corner := TalkBoxLayer.new()
	corner.name = "Top Left Ornament"
	corner.position = TalkBoxLayer.Position.TOP_LEFT
	corner.depth = TalkBoxLayer.Depth.ABOVE
	corner.visual_source = TalkBoxLayer.Source.IMAGE
	corner.texture = gem
	corner.scale = 2

	var name_box := TalkBoxLayer.new()
	name_box.name = "Name Box"
	name_box.slot = TalkBoxLayer.Slot.SPEAKER_NAME
	name_box.depth = TalkBoxLayer.Depth.BEHIND
	name_box.visual_source = TalkBoxLayer.Source.FLAT_STYLE
	(name_box.style as StyleBoxFlat).bg_color = Color(0.48, 0.18, 0.11)
	name_box.padding = Vector4(8, 2, 8, 2)

	template.name_placement = TalkBoxTemplate.NamePlacement.TOP_EDGE
	template.layers.append(corner)
	template.layers.append(name_box)
#endregion


func _build() -> void:
	var talk := NPCTalkKit.new()
	talk.name = "NPCTalkKit"
	add_child(talk)


func _verify() -> Array[String]:
	var failures: Array[String] = []
	var talk: NPCTalkKit = $NPCTalkKit

	use_a_template(talk)
	if talk.dialogue_boxes.is_empty():
		failures.append("dialogue box: the preset did not load")
	elif talk.dialogue_boxes[0].font != null or talk.dialogue_boxes[0].base.style is not StyleBoxFlat:
		failures.append("dialogue box: a shipped template must bundle no font and no texture")

	var style := _instant_box()
	warm_up_the_box(style)
	var painted := style.build_panel_style() as StyleBoxFlat
	if painted == null or painted.get_corner_radius(CORNER_TOP_LEFT) != 18:
		failures.append("dialogue box: the tweaks did not reach the generated StyleBox")

	# A theme must actually win, or the escape hatch is decoration.
	var label := RichTextLabel.new()
	follow_the_project_theme(style, Theme.new())
	style.apply_to(PanelContainer.new(), label)
	if label.has_theme_font_size_override("normal_font_size"):
		failures.append("dialogue box: paint overrides must stand down under a theme")
	label.free()

	# The resolution chain, end to end.
	var conversation := TalkConversation.new()
	conversation.conversation_id = &"tale"
	var plain := TalkLine.new()
	plain.text = "One."
	var flashback := TalkLine.new()
	flashback.text = "Two."
	conversation.lines = [plain, flashback]
	conversation.default_dialogue_box = style
	talk.conversations = [conversation]
	var owned: Array[TalkBoxTemplate] = [style]
	talk.dialogue_boxes = owned

	var memory := _instant_box()
	use_another_box_for_one_line(flashback, memory)
	talk.play(&"tale")
	if talk._box.effective_template() != style:
		failures.append("dialogue box: the conversation default should paint the first line")
	talk.advance()
	if talk._box.effective_template() != memory:
		failures.append("dialogue box: a line override should paint its own line")
	talk.stop()

	# And the line after an override goes back to the default on its own.
	var third := TalkLine.new()
	third.text = "Three."
	conversation.lines = [plain, flashback, third]
	talk.play(&"tale")
	talk.advance()
	talk.advance()
	if talk._box.effective_template() != style:
		failures.append("dialogue box: the line after an override must return to the default")
	talk.stop()

	var warning := _instant_box()
	use_another_box_for_a_conversation(conversation, warning)
	talk.play(&"tale")
	if talk._box.effective_template() != warning:
		failures.append("dialogue box: changing the conversation default should repaint it")
	talk.stop()

	var framed := _instant_box()
	var art := ImageTexture.create_from_image(Image.create_empty(24, 24, false, Image.FORMAT_RGBA8))
	use_a_pixel_frame(framed, art)
	if not framed.drawn_panel_style() is StyleBoxTexture or framed.effective_padding() != Vector4(24, 24, 24, 24):
		failures.append("dialogue box: a nine-slice frame should pad by its margins at ×3")
	add_an_ornament_and_a_name_box(framed, art)
	if framed.layers_for(TalkBoxLayer.Slot.PANEL).size() != 1 or framed.layers_for(TalkBoxLayer.Slot.SPEAKER_NAME).size() != 1:
		failures.append("dialogue box: the ornament and the name box should land on their slots")

	var one := NPCTalkKit.new()
	var two := NPCTalkKit.new()
	share_one_template([one, two], style)
	if one.dialogue_boxes != two.dialogue_boxes:
		failures.append("dialogue box: sharing must hand both NPCs the same resource")
	one.free()
	two.free()
	return failures


## Typing speed lives on the dialogue box now, so turning it off for a scripted
## run means handing the node a box that types instantly.
func _instant_box() -> TalkBoxTemplate:
	var template := TalkBoxTemplate.new()
	template.typewriter_speed = 0.0
	return template

Nine-slice dialogue box ​

A frame from an asset pack: Panel › Appearance: Nine-slice Texture, choose the image, then set Nine Slice Margins. The margins are drawn as guide lines on the image itself, so you can see which corners stay unstretched.

  • Nine Slice Stretch fills the edges and centre with Stretch, Tile or Tile Fit.
  • Nine Slice Scale draws pixel art at ×2, ×3 or ×4. Set Image Filter to Pixel (Nearest) and the pixels stay hard-edged at any scale.
gd
extends Node

## Docs: /guide/dialogue-box — what the box is and how it looks.

func _ready() -> void:
	_build()


#region template
# Three templates ship with the addon. None bundle a font or a texture, so
# they inherit whatever font your project already uses.
func use_a_template(talk: NPCTalkKit) -> void:
	# A typed array, because that is what the node carries. Assigning a bare
	# `[...]` through an untyped `$NPCTalkKit` fails at run time, not compile.
	var boxes: Array[TalkBoxTemplate] = [
		preload("res://addons/npc_talkkit/templates/terminal.tres")]
	talk.dialogue_boxes = boxes
#endregion


#region share
# A style is an ordinary Resource. Save one as a .tres, assign it to twenty
# NPCs, edit it once, and all twenty change. Godot's Make Unique lets one of
# them diverge. There is no TalkKit-specific sharing mechanism to learn.
func share_one_template(npcs: Array[NPCTalkKit], template: TalkBoxTemplate) -> void:
	var boxes: Array[TalkBoxTemplate] = [template]
	for npc in npcs:
		npc.dialogue_boxes = boxes
#endregion


#region tweak
# Styles hold appearance only — no target, no NodePath, no per-character data.
# That is what makes sharing safe.
func warm_up_the_box(template: TalkBoxTemplate) -> void:
	# The Panel's Base Appearance is a plain Godot StyleBoxFlat.
	var panel := template.base.style as StyleBoxFlat
	panel.bg_color = Color(0.14, 0.09, 0.06, 0.96)
	panel.border_color = Color(0.82, 0.58, 0.28)
	panel.set_corner_radius_all(18)
	template.text_size = 21
	template.open_transition = "slide"
	template.transition_duration = 0.22
#endregion


#region chain
# Two levels, and only two:
#
#   line.dialogue_box  →  conversation.default_dialogue_box
#
# A conversation says once which box paints it. A line says so only when it is
# the exception — a flashback, an interruption — and the line after it goes
# back to the default without being told to.
func use_another_box_for_one_line(line: TalkLine, flashback: TalkBoxTemplate) -> void:
	line.dialogue_box = flashback


func use_another_box_for_a_conversation(conversation: TalkConversation, warning: TalkBoxTemplate) -> void:
	conversation.default_dialogue_box = warning
#endregion


#region theme
# Already have a UI theme? Assign it and the box stops overriding fonts, sizes
# and text colours, so your theme wins. The Panel, padding and layers always
# apply: a Theme styles the text, never removes the frame.
func follow_the_project_theme(template: TalkBoxTemplate, theme: Theme) -> void:
	template.theme = theme
#endregion


#region nine_slice
# A frame from an asset pack: the Panel's Base Appearance becomes a nine-slice.
# Scale keeps pixel art crisp; padding follows the frame's margins unless set
# to Custom.
func use_a_pixel_frame(template: TalkBoxTemplate, frame: Texture2D) -> void:
	template.base.visual_source = TalkBoxLayer.Source.NINE_SLICE
	var panel := template.base.style as StyleBoxTexture
	panel.texture = frame
	panel.set_texture_margin_all(8)
	template.base.scale = 3
	template.base.filter = CanvasItem.TEXTURE_FILTER_NEAREST
#endregion


#region layers
# Layers decorate the Panel, the Speaker Name and the Dialogue Visual. Each
# draws a native resource: here an AtlasTexture region in a corner, and a
# StyleBoxFlat name box behind the name.
func add_an_ornament_and_a_name_box(template: TalkBoxTemplate, ornaments: Texture2D) -> void:
	var gem := AtlasTexture.new()
	gem.atlas = ornaments
	gem.region = Rect2(0, 0, 16, 16)
	var corner := TalkBoxLayer.new()
	corner.name = "Top Left Ornament"
	corner.position = TalkBoxLayer.Position.TOP_LEFT
	corner.depth = TalkBoxLayer.Depth.ABOVE
	corner.visual_source = TalkBoxLayer.Source.IMAGE
	corner.texture = gem
	corner.scale = 2

	var name_box := TalkBoxLayer.new()
	name_box.name = "Name Box"
	name_box.slot = TalkBoxLayer.Slot.SPEAKER_NAME
	name_box.depth = TalkBoxLayer.Depth.BEHIND
	name_box.visual_source = TalkBoxLayer.Source.FLAT_STYLE
	(name_box.style as StyleBoxFlat).bg_color = Color(0.48, 0.18, 0.11)
	name_box.padding = Vector4(8, 2, 8, 2)

	template.name_placement = TalkBoxTemplate.NamePlacement.TOP_EDGE
	template.layers.append(corner)
	template.layers.append(name_box)
#endregion


func _build() -> void:
	var talk := NPCTalkKit.new()
	talk.name = "NPCTalkKit"
	add_child(talk)


func _verify() -> Array[String]:
	var failures: Array[String] = []
	var talk: NPCTalkKit = $NPCTalkKit

	use_a_template(talk)
	if talk.dialogue_boxes.is_empty():
		failures.append("dialogue box: the preset did not load")
	elif talk.dialogue_boxes[0].font != null or talk.dialogue_boxes[0].base.style is not StyleBoxFlat:
		failures.append("dialogue box: a shipped template must bundle no font and no texture")

	var style := _instant_box()
	warm_up_the_box(style)
	var painted := style.build_panel_style() as StyleBoxFlat
	if painted == null or painted.get_corner_radius(CORNER_TOP_LEFT) != 18:
		failures.append("dialogue box: the tweaks did not reach the generated StyleBox")

	# A theme must actually win, or the escape hatch is decoration.
	var label := RichTextLabel.new()
	follow_the_project_theme(style, Theme.new())
	style.apply_to(PanelContainer.new(), label)
	if label.has_theme_font_size_override("normal_font_size"):
		failures.append("dialogue box: paint overrides must stand down under a theme")
	label.free()

	# The resolution chain, end to end.
	var conversation := TalkConversation.new()
	conversation.conversation_id = &"tale"
	var plain := TalkLine.new()
	plain.text = "One."
	var flashback := TalkLine.new()
	flashback.text = "Two."
	conversation.lines = [plain, flashback]
	conversation.default_dialogue_box = style
	talk.conversations = [conversation]
	var owned: Array[TalkBoxTemplate] = [style]
	talk.dialogue_boxes = owned

	var memory := _instant_box()
	use_another_box_for_one_line(flashback, memory)
	talk.play(&"tale")
	if talk._box.effective_template() != style:
		failures.append("dialogue box: the conversation default should paint the first line")
	talk.advance()
	if talk._box.effective_template() != memory:
		failures.append("dialogue box: a line override should paint its own line")
	talk.stop()

	# And the line after an override goes back to the default on its own.
	var third := TalkLine.new()
	third.text = "Three."
	conversation.lines = [plain, flashback, third]
	talk.play(&"tale")
	talk.advance()
	talk.advance()
	if talk._box.effective_template() != style:
		failures.append("dialogue box: the line after an override must return to the default")
	talk.stop()

	var warning := _instant_box()
	use_another_box_for_a_conversation(conversation, warning)
	talk.play(&"tale")
	if talk._box.effective_template() != warning:
		failures.append("dialogue box: changing the conversation default should repaint it")
	talk.stop()

	var framed := _instant_box()
	var art := ImageTexture.create_from_image(Image.create_empty(24, 24, false, Image.FORMAT_RGBA8))
	use_a_pixel_frame(framed, art)
	if not framed.drawn_panel_style() is StyleBoxTexture or framed.effective_padding() != Vector4(24, 24, 24, 24):
		failures.append("dialogue box: a nine-slice frame should pad by its margins at ×3")
	add_an_ornament_and_a_name_box(framed, art)
	if framed.layers_for(TalkBoxLayer.Slot.PANEL).size() != 1 or framed.layers_for(TalkBoxLayer.Slot.SPEAKER_NAME).size() != 1:
		failures.append("dialogue box: the ornament and the name box should land on their slots")

	var one := NPCTalkKit.new()
	var two := NPCTalkKit.new()
	share_one_template([one, two], style)
	if one.dialogue_boxes != two.dialogue_boxes:
		failures.append("dialogue box: sharing must hand both NPCs the same resource")
	one.free()
	two.free()
	return failures


## Typing speed lives on the dialogue box now, so turning it off for a scripted
## run means handing the node a box that types instantly.
func _instant_box() -> TalkBoxTemplate:
	var template := TalkBoxTemplate.new()
	template.typewriter_speed = 0.0
	return template

Padding ​

There is one padding, and the editor says which numbers apply:

  • Flat Style and Image have no margins of their own, so you set the padding directly.
  • Nine-slice Texture and a StyleBox Existing Godot Resource default to Use style margins, shown read-only, for example 24 · 24 · 24 · 24 px for an 8 px margin at ×3. Choose Custom to override them.

The same Padding row appears on a name box and a frame, where it is the space around the name or the portrait. Padding is never written into your StyleBox, so a shared resource stays as it is.

Speaker Name: inline or on the top edge ​

Speaker Name › Placement puts the name Inline, above the text, or On top edge, across the Panel's top border, with an alignment and an offset.

To give the name a box behind it, choose Speaker Name › Name Box › + Add Name Box. The box grows with the name, a nine-slice box stretches cleanly, and the box hides whenever the name does.

A single name box is edited right inside Speaker Name, with the Appearance editor the Panel uses. With two or more, the group shows how many, and Open in Layers opens them.

Portrait frame ​

Dialogue Visual › Frame › + Add Frame adds a layer on the portrait. Give it Above content to draw a border over the portrait, or Behind content for a backing card. The portrait stays your speaker's visual; the frame only decorates it, and hides when a line has no visual. Like a name box, a single frame is edited right inside Dialogue Visual.

Tail and Continue Indicator ​

Tail:

  • On a flat panel the tail takes the panel's colour automatically.
  • On a textured panel, choose Colour (the Sample panel colour button picks the frame's colour) or Image. An image tail turns toward the speaker.
  • Tail Overlap tucks the tail under the border, so the two read as one shape.

Continue Indicator:

  • Choose a Glyph such as ▼, or an Image.
  • Motion adds an optional idle Bounce or Blink.
  • The indicator shows only once the player can advance.

Corner ornaments and separate edges ​

Panel › Decorations › + Add Decoration, or click a corner square on the Anatomy Map: Add Layer opens with Panel and that corner already chosen.

  • Corners use the image's own size by default and sit centred on the corner, so they overhang it. Offset moves them, and Scale keeps pixel art crisp.
  • Edges have a Fit (Stretch, Tile, Tile Fit, Keep size) and an Edge Inset, which leaves room at both ends so an edge runs between two ornaments.

Overhanging decorations are never clipped. When the box is placed on screen, the overhang is counted too, so an ornament does not end up off-screen.

gd
extends Node

## Docs: /guide/dialogue-box — what the box is and how it looks.

func _ready() -> void:
	_build()


#region template
# Three templates ship with the addon. None bundle a font or a texture, so
# they inherit whatever font your project already uses.
func use_a_template(talk: NPCTalkKit) -> void:
	# A typed array, because that is what the node carries. Assigning a bare
	# `[...]` through an untyped `$NPCTalkKit` fails at run time, not compile.
	var boxes: Array[TalkBoxTemplate] = [
		preload("res://addons/npc_talkkit/templates/terminal.tres")]
	talk.dialogue_boxes = boxes
#endregion


#region share
# A style is an ordinary Resource. Save one as a .tres, assign it to twenty
# NPCs, edit it once, and all twenty change. Godot's Make Unique lets one of
# them diverge. There is no TalkKit-specific sharing mechanism to learn.
func share_one_template(npcs: Array[NPCTalkKit], template: TalkBoxTemplate) -> void:
	var boxes: Array[TalkBoxTemplate] = [template]
	for npc in npcs:
		npc.dialogue_boxes = boxes
#endregion


#region tweak
# Styles hold appearance only — no target, no NodePath, no per-character data.
# That is what makes sharing safe.
func warm_up_the_box(template: TalkBoxTemplate) -> void:
	# The Panel's Base Appearance is a plain Godot StyleBoxFlat.
	var panel := template.base.style as StyleBoxFlat
	panel.bg_color = Color(0.14, 0.09, 0.06, 0.96)
	panel.border_color = Color(0.82, 0.58, 0.28)
	panel.set_corner_radius_all(18)
	template.text_size = 21
	template.open_transition = "slide"
	template.transition_duration = 0.22
#endregion


#region chain
# Two levels, and only two:
#
#   line.dialogue_box  →  conversation.default_dialogue_box
#
# A conversation says once which box paints it. A line says so only when it is
# the exception — a flashback, an interruption — and the line after it goes
# back to the default without being told to.
func use_another_box_for_one_line(line: TalkLine, flashback: TalkBoxTemplate) -> void:
	line.dialogue_box = flashback


func use_another_box_for_a_conversation(conversation: TalkConversation, warning: TalkBoxTemplate) -> void:
	conversation.default_dialogue_box = warning
#endregion


#region theme
# Already have a UI theme? Assign it and the box stops overriding fonts, sizes
# and text colours, so your theme wins. The Panel, padding and layers always
# apply: a Theme styles the text, never removes the frame.
func follow_the_project_theme(template: TalkBoxTemplate, theme: Theme) -> void:
	template.theme = theme
#endregion


#region nine_slice
# A frame from an asset pack: the Panel's Base Appearance becomes a nine-slice.
# Scale keeps pixel art crisp; padding follows the frame's margins unless set
# to Custom.
func use_a_pixel_frame(template: TalkBoxTemplate, frame: Texture2D) -> void:
	template.base.visual_source = TalkBoxLayer.Source.NINE_SLICE
	var panel := template.base.style as StyleBoxTexture
	panel.texture = frame
	panel.set_texture_margin_all(8)
	template.base.scale = 3
	template.base.filter = CanvasItem.TEXTURE_FILTER_NEAREST
#endregion


#region layers
# Layers decorate the Panel, the Speaker Name and the Dialogue Visual. Each
# draws a native resource: here an AtlasTexture region in a corner, and a
# StyleBoxFlat name box behind the name.
func add_an_ornament_and_a_name_box(template: TalkBoxTemplate, ornaments: Texture2D) -> void:
	var gem := AtlasTexture.new()
	gem.atlas = ornaments
	gem.region = Rect2(0, 0, 16, 16)
	var corner := TalkBoxLayer.new()
	corner.name = "Top Left Ornament"
	corner.position = TalkBoxLayer.Position.TOP_LEFT
	corner.depth = TalkBoxLayer.Depth.ABOVE
	corner.visual_source = TalkBoxLayer.Source.IMAGE
	corner.texture = gem
	corner.scale = 2

	var name_box := TalkBoxLayer.new()
	name_box.name = "Name Box"
	name_box.slot = TalkBoxLayer.Slot.SPEAKER_NAME
	name_box.depth = TalkBoxLayer.Depth.BEHIND
	name_box.visual_source = TalkBoxLayer.Source.FLAT_STYLE
	(name_box.style as StyleBoxFlat).bg_color = Color(0.48, 0.18, 0.11)
	name_box.padding = Vector4(8, 2, 8, 2)

	template.name_placement = TalkBoxTemplate.NamePlacement.TOP_EDGE
	template.layers.append(corner)
	template.layers.append(name_box)
#endregion


func _build() -> void:
	var talk := NPCTalkKit.new()
	talk.name = "NPCTalkKit"
	add_child(talk)


func _verify() -> Array[String]:
	var failures: Array[String] = []
	var talk: NPCTalkKit = $NPCTalkKit

	use_a_template(talk)
	if talk.dialogue_boxes.is_empty():
		failures.append("dialogue box: the preset did not load")
	elif talk.dialogue_boxes[0].font != null or talk.dialogue_boxes[0].base.style is not StyleBoxFlat:
		failures.append("dialogue box: a shipped template must bundle no font and no texture")

	var style := _instant_box()
	warm_up_the_box(style)
	var painted := style.build_panel_style() as StyleBoxFlat
	if painted == null or painted.get_corner_radius(CORNER_TOP_LEFT) != 18:
		failures.append("dialogue box: the tweaks did not reach the generated StyleBox")

	# A theme must actually win, or the escape hatch is decoration.
	var label := RichTextLabel.new()
	follow_the_project_theme(style, Theme.new())
	style.apply_to(PanelContainer.new(), label)
	if label.has_theme_font_size_override("normal_font_size"):
		failures.append("dialogue box: paint overrides must stand down under a theme")
	label.free()

	# The resolution chain, end to end.
	var conversation := TalkConversation.new()
	conversation.conversation_id = &"tale"
	var plain := TalkLine.new()
	plain.text = "One."
	var flashback := TalkLine.new()
	flashback.text = "Two."
	conversation.lines = [plain, flashback]
	conversation.default_dialogue_box = style
	talk.conversations = [conversation]
	var owned: Array[TalkBoxTemplate] = [style]
	talk.dialogue_boxes = owned

	var memory := _instant_box()
	use_another_box_for_one_line(flashback, memory)
	talk.play(&"tale")
	if talk._box.effective_template() != style:
		failures.append("dialogue box: the conversation default should paint the first line")
	talk.advance()
	if talk._box.effective_template() != memory:
		failures.append("dialogue box: a line override should paint its own line")
	talk.stop()

	# And the line after an override goes back to the default on its own.
	var third := TalkLine.new()
	third.text = "Three."
	conversation.lines = [plain, flashback, third]
	talk.play(&"tale")
	talk.advance()
	talk.advance()
	if talk._box.effective_template() != style:
		failures.append("dialogue box: the line after an override must return to the default")
	talk.stop()

	var warning := _instant_box()
	use_another_box_for_a_conversation(conversation, warning)
	talk.play(&"tale")
	if talk._box.effective_template() != warning:
		failures.append("dialogue box: changing the conversation default should repaint it")
	talk.stop()

	var framed := _instant_box()
	var art := ImageTexture.create_from_image(Image.create_empty(24, 24, false, Image.FORMAT_RGBA8))
	use_a_pixel_frame(framed, art)
	if not framed.drawn_panel_style() is StyleBoxTexture or framed.effective_padding() != Vector4(24, 24, 24, 24):
		failures.append("dialogue box: a nine-slice frame should pad by its margins at ×3")
	add_an_ornament_and_a_name_box(framed, art)
	if framed.layers_for(TalkBoxLayer.Slot.PANEL).size() != 1 or framed.layers_for(TalkBoxLayer.Slot.SPEAKER_NAME).size() != 1:
		failures.append("dialogue box: the ornament and the name box should land on their slots")

	var one := NPCTalkKit.new()
	var two := NPCTalkKit.new()
	share_one_template([one, two], style)
	if one.dialogue_boxes != two.dialogue_boxes:
		failures.append("dialogue box: sharing must hand both NPCs the same resource")
	one.free()
	two.free()
	return failures


## Typing speed lives on the dialogue box now, so turning it off for a scripted
## run means handing the node a box that types instantly.
func _instant_box() -> TalkBoxTemplate:
	var template := TalkBoxTemplate.new()
	template.typewriter_speed = 0.0
	return template

Multiple layers and ordering ​

Open Layers (n) → above the settings to see every layer grouped by slot and depth. In the list you can:

  • tick a layer on or off;
  • drag it to reorder it, or onto Behind content / Above content to change its depth;
  • duplicate or remove it.

Selecting a layer outlines the area it affects in the preview. A few rules:

  • ← All settings goes back.
  • Layers stay on their slot. To move one, change its Slot.
  • The Base Appearance is listed first, with a link back to the Panel group.
  • Every change is a normal editor undo step.

A second background is simply another Fill layer; there is no separate system for it.

Atlas-based assets ​

Anywhere an image is accepted, an AtlasTexture works and draws only its region. This includes ornaments, frames, edges, overlays, the tail and the indicator. Tiling repeats the region, not the whole atlas, so one sheet can hold a gem, a tail and an arrow.

Theme precedence ​

A Theme in Advanced › Theme governs fonts, font sizes and text colours. While one is assigned, those settings are read-only with a note saying the Theme controls them. The Panel, padding and layers always apply: a Theme never removes your frame.

gd
extends Node

## Docs: /guide/dialogue-box — what the box is and how it looks.

func _ready() -> void:
	_build()


#region template
# Three templates ship with the addon. None bundle a font or a texture, so
# they inherit whatever font your project already uses.
func use_a_template(talk: NPCTalkKit) -> void:
	# A typed array, because that is what the node carries. Assigning a bare
	# `[...]` through an untyped `$NPCTalkKit` fails at run time, not compile.
	var boxes: Array[TalkBoxTemplate] = [
		preload("res://addons/npc_talkkit/templates/terminal.tres")]
	talk.dialogue_boxes = boxes
#endregion


#region share
# A style is an ordinary Resource. Save one as a .tres, assign it to twenty
# NPCs, edit it once, and all twenty change. Godot's Make Unique lets one of
# them diverge. There is no TalkKit-specific sharing mechanism to learn.
func share_one_template(npcs: Array[NPCTalkKit], template: TalkBoxTemplate) -> void:
	var boxes: Array[TalkBoxTemplate] = [template]
	for npc in npcs:
		npc.dialogue_boxes = boxes
#endregion


#region tweak
# Styles hold appearance only — no target, no NodePath, no per-character data.
# That is what makes sharing safe.
func warm_up_the_box(template: TalkBoxTemplate) -> void:
	# The Panel's Base Appearance is a plain Godot StyleBoxFlat.
	var panel := template.base.style as StyleBoxFlat
	panel.bg_color = Color(0.14, 0.09, 0.06, 0.96)
	panel.border_color = Color(0.82, 0.58, 0.28)
	panel.set_corner_radius_all(18)
	template.text_size = 21
	template.open_transition = "slide"
	template.transition_duration = 0.22
#endregion


#region chain
# Two levels, and only two:
#
#   line.dialogue_box  →  conversation.default_dialogue_box
#
# A conversation says once which box paints it. A line says so only when it is
# the exception — a flashback, an interruption — and the line after it goes
# back to the default without being told to.
func use_another_box_for_one_line(line: TalkLine, flashback: TalkBoxTemplate) -> void:
	line.dialogue_box = flashback


func use_another_box_for_a_conversation(conversation: TalkConversation, warning: TalkBoxTemplate) -> void:
	conversation.default_dialogue_box = warning
#endregion


#region theme
# Already have a UI theme? Assign it and the box stops overriding fonts, sizes
# and text colours, so your theme wins. The Panel, padding and layers always
# apply: a Theme styles the text, never removes the frame.
func follow_the_project_theme(template: TalkBoxTemplate, theme: Theme) -> void:
	template.theme = theme
#endregion


#region nine_slice
# A frame from an asset pack: the Panel's Base Appearance becomes a nine-slice.
# Scale keeps pixel art crisp; padding follows the frame's margins unless set
# to Custom.
func use_a_pixel_frame(template: TalkBoxTemplate, frame: Texture2D) -> void:
	template.base.visual_source = TalkBoxLayer.Source.NINE_SLICE
	var panel := template.base.style as StyleBoxTexture
	panel.texture = frame
	panel.set_texture_margin_all(8)
	template.base.scale = 3
	template.base.filter = CanvasItem.TEXTURE_FILTER_NEAREST
#endregion


#region layers
# Layers decorate the Panel, the Speaker Name and the Dialogue Visual. Each
# draws a native resource: here an AtlasTexture region in a corner, and a
# StyleBoxFlat name box behind the name.
func add_an_ornament_and_a_name_box(template: TalkBoxTemplate, ornaments: Texture2D) -> void:
	var gem := AtlasTexture.new()
	gem.atlas = ornaments
	gem.region = Rect2(0, 0, 16, 16)
	var corner := TalkBoxLayer.new()
	corner.name = "Top Left Ornament"
	corner.position = TalkBoxLayer.Position.TOP_LEFT
	corner.depth = TalkBoxLayer.Depth.ABOVE
	corner.visual_source = TalkBoxLayer.Source.IMAGE
	corner.texture = gem
	corner.scale = 2

	var name_box := TalkBoxLayer.new()
	name_box.name = "Name Box"
	name_box.slot = TalkBoxLayer.Slot.SPEAKER_NAME
	name_box.depth = TalkBoxLayer.Depth.BEHIND
	name_box.visual_source = TalkBoxLayer.Source.FLAT_STYLE
	(name_box.style as StyleBoxFlat).bg_color = Color(0.48, 0.18, 0.11)
	name_box.padding = Vector4(8, 2, 8, 2)

	template.name_placement = TalkBoxTemplate.NamePlacement.TOP_EDGE
	template.layers.append(corner)
	template.layers.append(name_box)
#endregion


func _build() -> void:
	var talk := NPCTalkKit.new()
	talk.name = "NPCTalkKit"
	add_child(talk)


func _verify() -> Array[String]:
	var failures: Array[String] = []
	var talk: NPCTalkKit = $NPCTalkKit

	use_a_template(talk)
	if talk.dialogue_boxes.is_empty():
		failures.append("dialogue box: the preset did not load")
	elif talk.dialogue_boxes[0].font != null or talk.dialogue_boxes[0].base.style is not StyleBoxFlat:
		failures.append("dialogue box: a shipped template must bundle no font and no texture")

	var style := _instant_box()
	warm_up_the_box(style)
	var painted := style.build_panel_style() as StyleBoxFlat
	if painted == null or painted.get_corner_radius(CORNER_TOP_LEFT) != 18:
		failures.append("dialogue box: the tweaks did not reach the generated StyleBox")

	# A theme must actually win, or the escape hatch is decoration.
	var label := RichTextLabel.new()
	follow_the_project_theme(style, Theme.new())
	style.apply_to(PanelContainer.new(), label)
	if label.has_theme_font_size_override("normal_font_size"):
		failures.append("dialogue box: paint overrides must stand down under a theme")
	label.free()

	# The resolution chain, end to end.
	var conversation := TalkConversation.new()
	conversation.conversation_id = &"tale"
	var plain := TalkLine.new()
	plain.text = "One."
	var flashback := TalkLine.new()
	flashback.text = "Two."
	conversation.lines = [plain, flashback]
	conversation.default_dialogue_box = style
	talk.conversations = [conversation]
	var owned: Array[TalkBoxTemplate] = [style]
	talk.dialogue_boxes = owned

	var memory := _instant_box()
	use_another_box_for_one_line(flashback, memory)
	talk.play(&"tale")
	if talk._box.effective_template() != style:
		failures.append("dialogue box: the conversation default should paint the first line")
	talk.advance()
	if talk._box.effective_template() != memory:
		failures.append("dialogue box: a line override should paint its own line")
	talk.stop()

	# And the line after an override goes back to the default on its own.
	var third := TalkLine.new()
	third.text = "Three."
	conversation.lines = [plain, flashback, third]
	talk.play(&"tale")
	talk.advance()
	talk.advance()
	if talk._box.effective_template() != style:
		failures.append("dialogue box: the line after an override must return to the default")
	talk.stop()

	var warning := _instant_box()
	use_another_box_for_a_conversation(conversation, warning)
	talk.play(&"tale")
	if talk._box.effective_template() != warning:
		failures.append("dialogue box: changing the conversation default should repaint it")
	talk.stop()

	var framed := _instant_box()
	var art := ImageTexture.create_from_image(Image.create_empty(24, 24, false, Image.FORMAT_RGBA8))
	use_a_pixel_frame(framed, art)
	if not framed.drawn_panel_style() is StyleBoxTexture or framed.effective_padding() != Vector4(24, 24, 24, 24):
		failures.append("dialogue box: a nine-slice frame should pad by its margins at ×3")
	add_an_ornament_and_a_name_box(framed, art)
	if framed.layers_for(TalkBoxLayer.Slot.PANEL).size() != 1 or framed.layers_for(TalkBoxLayer.Slot.SPEAKER_NAME).size() != 1:
		failures.append("dialogue box: the ornament and the name box should land on their slots")

	var one := NPCTalkKit.new()
	var two := NPCTalkKit.new()
	share_one_template([one, two], style)
	if one.dialogue_boxes != two.dialogue_boxes:
		failures.append("dialogue box: sharing must hand both NPCs the same resource")
	one.free()
	two.free()
	return failures


## Typing speed lives on the dialogue box now, so turning it off for a scripted
## run means handing the node a box that types instantly.
func _instant_box() -> TalkBoxTemplate:
	var template := TalkBoxTemplate.new()
	template.typewriter_speed = 0.0
	return template

When to use a custom renderer ​

Layers cover static skins. Use a renderer of your own when the structure itself changes, for example:

  • a chat thread;
  • an animated, Persona-style composition;
  • shader-driven UI.

A custom renderer still receives layers for the slots it has. The Layers view marks any it cannot draw as not shown by this renderer.

GDScript-first. No telemetry, no network requests, no AI service dependency in the shipped addon.