# Narrative UI Patterns

> **Purpose**: Recipe for Claude Code. The choice card, stat chip, affliction grid, equipment grid, narrative panel, and narrative HUD patterns that make RPG UI feel *authored*, not generated. Values from Disco Elysium, Hades, Persona 5, Darkest Dungeon, Slay the Spire, Dark Souls. The marinade — not UI system architecture.

---

## 1. Choice Card Pattern

The card that represents a narrative option. Not a button with text — a card with **structure, states, and metadata** that communicates consequence before the player picks.

### Node Hierarchy

```
PanelContainer (choice_card)
 └─ MarginContainer (padding: 12px all sides)
     └─ HBoxContainer (separation: 10)
         ├─ TextureRect (icon, 32x32, STRETCH_KEEP_ASPECT_CENTERED)
         └─ VBoxContainer (SIZE_EXPAND_FILL)
             ├─ Label (title — bold, 16px)
             ├─ RichTextLabel (description — regular, 13px, autowrap, fit_content = true)
             └─ HBoxContainer (metadata_row, separation: 12)
                 ├─ StatChip (skill check %)
                 ├─ StatChip (cost)
                 └─ StatChip (alignment shift)
```

The icon is optional — use it for action type (sword for attack, speech bubble for persuade, eye for perception). When absent, the HBox still works because TextureRect collapses at `custom_minimum_size = Vector2.ZERO`.

### The Five States

Every choice card needs five visual states. Miss one and the UI feels incomplete during edge cases.

| State | Visual | Transition | When |
|---|---|---|---|
| **idle** | Base panel, text at full alpha | — | Default |
| **hover** | Scale 1.02, subtle glow (panel StyleBox shadow expand 2px), accent border left edge | 100ms ease-out | Mouse/keyboard focus |
| **selected** | Accent color border (3px left), background tint, slight scale 1.01 | 80ms | Confirmed choice |
| **locked** | Greyed out (modulate 0.4), lock icon replaces action icon, description replaced by lock reason | Instant | Stat/flag requirement not met |
| **skill_check** | Probability displayed in metadata row, color-coded (green >70%, yellow 40-70%, red <40%) | Instant + value tween on hover | Disco Elysium-style |

### Choice Card Script

```gdscript
# choice_card.gd
class_name ChoiceCard
extends PanelContainer

signal card_selected(card: ChoiceCard)
signal card_hovered(card: ChoiceCard)

enum State { IDLE, HOVER, SELECTED, LOCKED, SKILL_CHECK }

@export var accent_color := Color("e6a23c")         # Warm amber — Disco Elysium uses warm accents
@export var locked_color := Color(0.35, 0.35, 0.4)  # Desaturated gray-blue
@export var hover_glow_color := Color(1.0, 1.0, 1.0, 0.06)

## Data
var choice_id: String
var choice_data: Dictionary

## Internal
var _state := State.IDLE
var _base_stylebox: StyleBoxFlat
var _hover_tween: Tween

@onready var icon: TextureRect = %Icon
@onready var title_label: Label = %Title
@onready var description_label: RichTextLabel = %Description
@onready var metadata_row: HBoxContainer = %MetadataRow


func _ready() -> void:
	mouse_entered.connect(_on_hover_enter)
	mouse_exited.connect(_on_hover_exit)
	gui_input.connect(_on_input)
	pivot_offset = size / 2.0

	# Cache the base stylebox for state resets
	_base_stylebox = get_theme_stylebox("panel").duplicate() as StyleBoxFlat
	mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND


func setup(data: Dictionary) -> void:
	choice_id = data.get("id", "")
	choice_data = data
	title_label.text = data.get("title", "")
	description_label.text = data.get("description", "")

	if data.has("icon"):
		icon.texture = data["icon"]
		icon.show()
	else:
		icon.hide()

	# Metadata chips
	_clear_metadata()
	if data.has("skill_check"):
		_add_skill_check_chip(data["skill_check"])
	if data.has("cost"):
		_add_cost_chip(data["cost"])
	if data.has("alignment"):
		_add_alignment_chip(data["alignment"])

	# Locked state
	if data.get("locked", false):
		_set_state(State.LOCKED)
		description_label.text = data.get("lock_reason", "[Locked]")
	elif data.has("skill_check"):
		_set_state(State.SKILL_CHECK)
	else:
		_set_state(State.IDLE)


func _set_state(new_state: State) -> void:
	_state = new_state
	match _state:
		State.IDLE:
			modulate = Color.WHITE
			_reset_stylebox()
		State.LOCKED:
			modulate = locked_color
			mouse_default_cursor_shape = Control.CURSOR_FORBIDDEN
		State.SKILL_CHECK:
			modulate = Color.WHITE
		_:
			pass


func _on_hover_enter() -> void:
	if _state == State.LOCKED:
		return
	card_hovered.emit(self)

	if _hover_tween:
		_hover_tween.kill()
	_hover_tween = create_tween().set_parallel(true)
	_hover_tween.tween_property(self, "scale",
		Vector2.ONE * 1.02, 0.1
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)

	# Left accent border
	var sb := _base_stylebox.duplicate() as StyleBoxFlat
	sb.border_width_left = 3
	sb.border_color = accent_color
	sb.shadow_size = 2
	sb.shadow_color = hover_glow_color
	add_theme_stylebox_override("panel", sb)


func _on_hover_exit() -> void:
	if _state == State.LOCKED:
		return
	if _state == State.SELECTED:
		return

	if _hover_tween:
		_hover_tween.kill()
	_hover_tween = create_tween().set_parallel(true)
	_hover_tween.tween_property(self, "scale",
		Vector2.ONE, 0.1
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
	_reset_stylebox()


func _on_input(event: InputEvent) -> void:
	if _state == State.LOCKED:
		return
	if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
		select()


func select() -> void:
	_state = State.SELECTED
	var sb := _base_stylebox.duplicate() as StyleBoxFlat
	sb.border_width_left = 3
	sb.border_color = accent_color
	sb.bg_color = Color(accent_color, 0.08)
	add_theme_stylebox_override("panel", sb)

	# Confirm bump
	var tw := create_tween()
	tw.tween_property(self, "scale",
		Vector2.ONE * 1.04, 0.06
	).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	tw.tween_property(self, "scale",
		Vector2.ONE * 1.01, 0.12
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)

	card_selected.emit(self)


func _reset_stylebox() -> void:
	add_theme_stylebox_override("panel", _base_stylebox.duplicate())


func _clear_metadata() -> void:
	for child in metadata_row.get_children():
		child.queue_free()


func _add_skill_check_chip(data: Dictionary) -> void:
	var chip := _create_stat_chip()
	var pct: int = data.get("chance", 50)
	chip.setup_skill_check(data.get("skill", ""), pct)
	metadata_row.add_child(chip)


func _add_cost_chip(data: Dictionary) -> void:
	var chip := _create_stat_chip()
	chip.setup_cost(data.get("resource", ""), data.get("amount", 0))
	metadata_row.add_child(chip)


func _add_alignment_chip(data: Dictionary) -> void:
	var chip := _create_stat_chip()
	chip.setup_alignment(data.get("direction", ""), data.get("amount", 0))
	metadata_row.add_child(chip)


func _create_stat_chip() -> StatChip:
	return StatChip.new()
```

### Disco Elysium Skill Check Display

Disco Elysium shows skill checks as `[Rhetoric — Medium 10]` with color coding. The probability isn't a percentage shown raw — it's a difficulty label + the target number. The player learns to read "Trivial" vs "Legendary" faster than "92%" vs "3%".

| Difficulty | Target Range | Color | Hex |
|---|---|---|---|
| Trivial | 6- | Bright green | `#73d216` |
| Easy | 7-8 | Green | `#4e9a06` |
| Medium | 9-10 | Yellow | `#c4a000` |
| Challenging | 11-12 | Orange | `#ce5c00` |
| Formidable | 13-14 | Red-orange | `#cc0000` |
| Heroic | 15-16 | Red | `#a40000` |
| Legendary | 17+ | Dark red | `#5c0000` |

**Persona 5 timed choices**: timer bar below the choice container, 5-8s for standard, 3s for pressure. The bar is a TextureProgressBar with a gradient from accent to red, shrinking left to right. When < 25% remaining, the bar pulses (scale 1.0 to 1.05, 0.3s cycle). On timeout, auto-select the top choice or silence option.

**Hades reward preview**: each card shows the boon icon + name + brief effect. The player decides based on the preview, not by reading walls of text. For choice cards: **title + one-line description max**. If you need more, put it in a tooltip on hover.

### Trap: Variable-Height Choice Cards

Choice cards in a VBox where one has a 3-line description and another has 1 line: the VBox height jumps on hover when focus changes. The player's eye loses tracking.

Fix: set `custom_minimum_size.y = 80` on every ChoiceCard (or whatever fits your longest expected description). All cards stay the same height. The visual grid stays stable.

```gdscript
# In the choice container setup
for card in choice_cards:
	card.custom_minimum_size.y = 80  # Uniform height — no layout jumps
```

---

## 2. Stat Chip Pattern

A compact, self-contained display for a single stat value. Designed to be tiled horizontally in rows — on choice cards, character panels, tooltips, equipment comparisons.

### Structure

```
HBoxContainer (stat_chip, separation: 4)
 ├─ TextureRect (icon, 16x16)
 ├─ Label (stat_name, 11px, muted color)
 └─ Label (value, 13px bold, color-coded)
```

Max height: 32px. Max width: flexible but typically 80-120px. Designed for horizontal rows of 2-4 chips.

### Stat Chip Script

```gdscript
# stat_chip.gd
class_name StatChip
extends HBoxContainer

const COLOR_BUFF := Color("73d216")      # Green — positive
const COLOR_DEBUFF := Color("ef2929")    # Red — negative
const COLOR_NEUTRAL := Color("babdb6")   # Muted gray — no change
const COLOR_SKILL_EASY := Color("4e9a06")
const COLOR_SKILL_MEDIUM := Color("c4a000")
const COLOR_SKILL_HARD := Color("cc0000")

var _icon: TextureRect
var _name_label: Label
var _value_label: Label


func _init() -> void:
	add_theme_constant_override("separation", 4)
	custom_minimum_size.y = 24

	_icon = TextureRect.new()
	_icon.custom_minimum_size = Vector2(16, 16)
	_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	add_child(_icon)

	_name_label = Label.new()
	_name_label.add_theme_font_size_override("font_size", 11)
	_name_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.75))
	add_child(_name_label)

	_value_label = Label.new()
	_value_label.add_theme_font_size_override("font_size", 13)
	add_child(_value_label)


func setup_stat(stat_name: String, value: int, icon_texture: Texture2D = null,
		delta: int = 0) -> void:
	_name_label.text = stat_name
	_value_label.text = str(value)

	if icon_texture:
		_icon.texture = icon_texture
		_icon.show()
	else:
		_icon.hide()

	# Color coding by delta
	if delta > 0:
		_value_label.add_theme_color_override("font_color", COLOR_BUFF)
	elif delta < 0:
		_value_label.add_theme_color_override("font_color", COLOR_DEBUFF)
	else:
		_value_label.add_theme_color_override("font_color", COLOR_NEUTRAL)


func setup_with_delta(stat_name: String, value: int, delta: int,
		icon_texture: Texture2D = null) -> void:
	setup_stat(stat_name, value, icon_texture, delta)

	if delta != 0:
		var sign := "+" if delta > 0 else ""
		_value_label.text = "%d (%s%d)" % [value, sign, delta]
		_animate_delta()


func setup_skill_check(skill_name: String, chance_pct: int) -> void:
	_icon.hide()
	_name_label.text = skill_name

	var difficulty := _get_difficulty_label(chance_pct)
	_value_label.text = "%s %d%%" % [difficulty, chance_pct]

	var color: Color
	if chance_pct >= 70:
		color = COLOR_SKILL_EASY
	elif chance_pct >= 40:
		color = COLOR_SKILL_MEDIUM
	else:
		color = COLOR_SKILL_HARD
	_value_label.add_theme_color_override("font_color", color)


func setup_cost(resource_name: String, amount: int) -> void:
	_name_label.text = resource_name
	_value_label.text = str(amount)
	_value_label.add_theme_color_override("font_color", COLOR_DEBUFF)


func setup_alignment(direction: String, amount: int) -> void:
	_icon.hide()
	var sign := "+" if amount > 0 else ""
	_name_label.text = direction
	_value_label.text = "%s%d" % [sign, amount]

	if amount > 0:
		_value_label.add_theme_color_override("font_color", COLOR_BUFF)
	elif amount < 0:
		_value_label.add_theme_color_override("font_color", COLOR_DEBUFF)
	else:
		_value_label.add_theme_color_override("font_color", COLOR_NEUTRAL)


func _animate_delta() -> void:
	_value_label.scale = Vector2.ONE * 1.3
	_value_label.pivot_offset = _value_label.size / 2.0
	var tw := create_tween()
	tw.tween_property(_value_label, "scale",
		Vector2.ONE, 0.25
	).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)


func _get_difficulty_label(chance_pct: int) -> String:
	if chance_pct >= 90:
		return "Trivial"
	elif chance_pct >= 70:
		return "Easy"
	elif chance_pct >= 50:
		return "Medium"
	elif chance_pct >= 35:
		return "Hard"
	elif chance_pct >= 20:
		return "Heroic"
	else:
		return "Legendary"
```

### Reference: Darkest Dungeon Stat Bars

Darkest Dungeon uses a vertical stat list where each row is icon + name + bar + number. The bar gives spatial sense (how full?), the number gives precision. For compact chips without bars, the **color-coded number** alone carries enough information — but only if the color language is consistent across the entire game. Green = always good, red = always bad, no exceptions.

### Reference: Hades Boon Stats

Hades shows boon effects as a single line: `"Attack deals 50% more damage"`. The number is highlighted in the boon's god color. No label/value split — just natural language with the key value emphasized. Consider this for tooltips and descriptions, even if the chip itself uses label+value.

---

## 3. Affliction Grid Pattern

Status effects, buffs, debuffs — displayed as a compact grid of icons. The visual noise problem is real: above 8 simultaneous icons, players stop reading them.

### Structure

```
GridContainer (affliction_grid, columns: 8)
 └─ [repeated] AfflictionTile
     └─ TextureRect (icon, 28x28)
         ├─ ColorRect (duration_overlay, anchored bottom, height 4px)
         └─ Label (stack_count, anchored bottom-right, 9px bold)
```

### Affliction Tile Script

```gdscript
# affliction_tile.gd
class_name AfflictionTile
extends Control

const TILE_SIZE := 32
const ICON_SIZE := 28
const NEGATIVE_TINT := Color(1.0, 0.6, 0.6)   # Red-ish overlay for debuffs
const POSITIVE_TINT := Color(0.7, 1.0, 0.7)    # Green-ish for buffs
const EXPIRING_PULSE_SPEED := 3.0               # Faster = more urgent

@export var affliction_data: Dictionary

var _icon: TextureRect
var _duration_bar: ColorRect
var _stack_label: Label
var _is_expiring := false


func _init() -> void:
	custom_minimum_size = Vector2(TILE_SIZE, TILE_SIZE)

	_icon = TextureRect.new()
	_icon.custom_minimum_size = Vector2(ICON_SIZE, ICON_SIZE)
	_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	_icon.set_anchors_preset(Control.PRESET_CENTER)
	add_child(_icon)

	# Duration bar — thin bar at the bottom showing remaining time
	_duration_bar = ColorRect.new()
	_duration_bar.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
	_duration_bar.custom_minimum_size = Vector2(0, 3)
	_duration_bar.offset_top = -3
	add_child(_duration_bar)

	# Stack counter — bottom-right corner
	_stack_label = Label.new()
	_stack_label.add_theme_font_size_override("font_size", 9)
	_stack_label.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
	_stack_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
	_stack_label.hide()
	add_child(_stack_label)

	# Tooltip on hover
	mouse_entered.connect(_on_hover)
	mouse_exited.connect(_on_hover_exit)


func setup(data: Dictionary) -> void:
	affliction_data = data

	if data.has("icon"):
		_icon.texture = data["icon"]

	# Tint by type
	var is_negative: bool = data.get("negative", false)
	_icon.modulate = NEGATIVE_TINT if is_negative else POSITIVE_TINT

	# Stack count
	var stacks: int = data.get("stacks", 1)
	if stacks > 1:
		_stack_label.text = str(stacks)
		_stack_label.show()
	else:
		_stack_label.hide()

	# Duration bar
	var duration_pct: float = data.get("duration_pct", 1.0)
	_duration_bar.scale.x = duration_pct
	_duration_bar.color = Color.RED.lerp(Color.GREEN, duration_pct)

	_is_expiring = duration_pct < 0.25


func _process(delta: float) -> void:
	if _is_expiring:
		# Pulse the icon alpha when about to expire
		_icon.modulate.a = 0.5 + 0.5 * sin(Time.get_ticks_msec() * 0.001 * EXPIRING_PULSE_SPEED * TAU)


func _on_hover() -> void:
	tooltip_text = affliction_data.get("tooltip", "")
	# Scale bump
	var tw := create_tween()
	pivot_offset = size / 2.0
	tw.tween_property(self, "scale", Vector2.ONE * 1.15, 0.08)\
		.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)


func _on_hover_exit() -> void:
	var tw := create_tween()
	tw.tween_property(self, "scale", Vector2.ONE, 0.08)
```

### Sorting and Visual Priority

Darkest Dungeon sorts afflictions left-to-right by severity. Slay the Spire sorts by type (debuffs first, then buffs). The rule: **negative effects first, then positive, within each group sort by remaining duration (shortest first)**. The most urgent information is always leftmost.

```gdscript
## Call after any affliction change
func sort_afflictions(tiles: Array[AfflictionTile]) -> void:
	tiles.sort_custom(func(a: AfflictionTile, b: AfflictionTile) -> bool:
		# Negatives first
		var a_neg: bool = a.affliction_data.get("negative", false)
		var b_neg: bool = b.affliction_data.get("negative", false)
		if a_neg != b_neg:
			return a_neg  # true (negative) sorts before false (positive)
		# Within same type, shortest duration first
		var a_dur: float = a.affliction_data.get("duration_pct", 1.0)
		var b_dur: float = b.affliction_data.get("duration_pct", 1.0)
		return a_dur < b_dur
	)
```

### Trap: Visual Noise Above 8 Icons

Darkest Dungeon caps visible afflictions at around 10, and even that gets noisy. Slay the Spire has the same problem — late-game builds with 15+ buffs become unreadable.

Fix: after 8 visible tiles, collapse remaining into a `"+3 more"` label. On hover over that label, show a popup grid with all effects. The main grid stays scannable, the detail is accessible.

```gdscript
const MAX_VISIBLE := 8

func refresh_grid(afflictions: Array[Dictionary]) -> void:
	_clear_grid()
	sort_afflictions(afflictions)

	for i in afflictions.size():
		if i < MAX_VISIBLE:
			var tile := AfflictionTile.new()
			tile.setup(afflictions[i])
			grid.add_child(tile)
		elif i == MAX_VISIBLE:
			var overflow_label := Label.new()
			overflow_label.text = "+%d" % (afflictions.size() - MAX_VISIBLE)
			overflow_label.add_theme_font_size_override("font_size", 11)
			overflow_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.75))
			grid.add_child(overflow_label)
```

---

## 4. Equipment Grid Pattern

Inventory slots for equipment. The grid is tactile or it's a spreadsheet — the difference is in hover states, empty slot treatment, and rarity borders.

### Structure

```
GridContainer (equipment_grid, columns: 4-6)
 └─ [repeated] EquipmentSlot (PanelContainer, 64x64 or 80x80)
     ├─ TextureRect (item_icon, centered, 48x48 or 64x64)
     ├─ ColorRect (rarity_border, full rect, 2px border via shader or stylebox)
     └─ TextureRect (equipped_indicator, 12x12, anchored top-right)
```

### Slot Sizes by Game Type

| Game type | Slot size | Icon size | Grid columns | Reference |
|---|---|---|---|---|
| Action RPG (Diablo) | 64x64 | 48x48 | 10-12 | Dense, lots of items |
| Narrative RPG | 80x80 | 60x60 | 4-6 | Fewer items, more readable |
| Souls-like (Dark Souls) | 80x80 | 64x64 | 5 | Equipment-focused, clear |
| Deckbuilder / Card RPG | 100x140 | card shaped | 3-5 | Cards, not squares |

### Equipment Slot Script

```gdscript
# equipment_slot.gd
class_name EquipmentSlot
extends PanelContainer

signal slot_clicked(slot: EquipmentSlot)
signal slot_hovered(slot: EquipmentSlot)

const SLOT_SIZE := 80
const ICON_SIZE := 60

## Rarity border colors — same language as the rest of the game
const RARITY_COLORS := {
	"common": Color(0.5, 0.5, 0.5, 0.3),
	"uncommon": Color(0.12, 1.0, 0.0, 0.6),
	"rare": Color(0.0, 0.44, 1.0, 0.7),
	"epic": Color(0.64, 0.21, 0.93, 0.8),
	"legendary": Color(1.0, 0.50, 0.0, 0.9),
}

var item_data: Dictionary
var is_empty := true

var _icon: TextureRect
var _rarity_border: Panel
var _equipped_indicator: TextureRect
var _empty_pattern: ColorRect


func _init() -> void:
	custom_minimum_size = Vector2(SLOT_SIZE, SLOT_SIZE)
	mouse_entered.connect(_on_hover)
	mouse_exited.connect(_on_hover_exit)
	gui_input.connect(_on_input)
	mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND


func _ready() -> void:
	pivot_offset = size / 2.0

	# Empty slot visual — dashed border feel via a dim outline
	_empty_pattern = ColorRect.new()
	_empty_pattern.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	_empty_pattern.color = Color(1.0, 1.0, 1.0, 0.04)
	add_child(_empty_pattern)

	# Item icon
	_icon = TextureRect.new()
	_icon.custom_minimum_size = Vector2(ICON_SIZE, ICON_SIZE)
	_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	_icon.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
	_icon.hide()
	add_child(_icon)

	# Rarity border — a Panel with StyleBoxFlat, border only
	_rarity_border = Panel.new()
	_rarity_border.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	var sb := StyleBoxFlat.new()
	sb.bg_color = Color.TRANSPARENT
	sb.border_width_left = 2
	sb.border_width_right = 2
	sb.border_width_top = 2
	sb.border_width_bottom = 2
	sb.corner_radius_top_left = 4
	sb.corner_radius_top_right = 4
	sb.corner_radius_bottom_left = 4
	sb.corner_radius_bottom_right = 4
	_rarity_border.add_theme_stylebox_override("panel", sb)
	_rarity_border.hide()
	_rarity_border.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(_rarity_border)

	# Equipped indicator — small icon, top-right
	_equipped_indicator = TextureRect.new()
	_equipped_indicator.custom_minimum_size = Vector2(12, 12)
	_equipped_indicator.set_anchors_preset(Control.PRESET_TOP_RIGHT)
	_equipped_indicator.offset_left = -16
	_equipped_indicator.offset_top = 4
	_equipped_indicator.hide()
	_equipped_indicator.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(_equipped_indicator)


func set_item(data: Dictionary) -> void:
	item_data = data
	is_empty = false
	_empty_pattern.hide()

	if data.has("icon"):
		_icon.texture = data["icon"]
		_icon.show()

	# Rarity border
	var rarity: String = data.get("rarity", "common")
	if RARITY_COLORS.has(rarity):
		var sb := _rarity_border.get_theme_stylebox("panel") as StyleBoxFlat
		sb.border_color = RARITY_COLORS[rarity]
		_rarity_border.show()

	# Equipped
	if data.get("equipped", false):
		_equipped_indicator.show()
	else:
		_equipped_indicator.hide()


func clear_item() -> void:
	item_data = {}
	is_empty = true
	_icon.hide()
	_rarity_border.hide()
	_equipped_indicator.hide()
	_empty_pattern.show()


func _on_hover() -> void:
	slot_hovered.emit(self)
	var tw := create_tween()
	tw.tween_property(self, "scale",
		Vector2.ONE * 1.05, 0.08
	).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)

	if not is_empty:
		# Brighten the icon slightly
		_icon.modulate = Color(1.15, 1.15, 1.15)


func _on_hover_exit() -> void:
	var tw := create_tween()
	tw.tween_property(self, "scale",
		Vector2.ONE, 0.08
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
	_icon.modulate = Color.WHITE


func _on_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
		slot_clicked.emit(self)
```

### Empty State Treatment

Dark Souls shows empty equipment slots as faded silhouettes of the expected item type (helmet shape, ring shape, weapon shape). This communicates what CAN go there without being visually noisy.

For a simpler approach: a dashed border (use a StyleBoxFlat with `draw_center = false` and low-alpha border) plus a very subtle pattern or icon silhouette at 10% opacity.

**Never leave empty slots as plain rectangles.** The player needs to distinguish "empty and available" from "this slot doesn't exist."

---

## 5. Narrative Panel Pattern

The full dialogue/narration panel — the most screen-dominant UI element in a narrative RPG. This is where choice cards, portraits, and text converge.

### Layout Variants

| Layout | Position | Width | Height | Reference |
|---|---|---|---|---|
| **Bottom bar** | Bottom, full width | 100% viewport | 180-220px | Persona 5, Fire Emblem |
| **Side panel** | Right side, partial height | 35-40% viewport | 60-80% viewport | Disco Elysium |
| **Center overlay** | Center, floating | 60-70% viewport | auto (content-driven) | Hades |
| **Full narrative** | Full screen, split layout | 100% viewport | 100% viewport | Visual novels |

### Bottom Bar Structure (most common)

```
PanelContainer (narrative_panel, anchored bottom, full width)
 └─ MarginContainer (padding: 16 all sides)
     └─ VBoxContainer (separation: 8)
         ├─ HBoxContainer (speaker_row, separation: 12)
         │   ├─ TextureRect (portrait, 96x96 or 128x128)
         │   └─ VBoxContainer
         │       ├─ Label (speaker_name, 14px bold, accent color)
         │       └─ Label (speaker_title, 11px, muted — optional)
         ├─ RichTextLabel (text_area, autowrap, bbcode_enabled, fit_content)
         └─ VBoxContainer (choice_container — filled with ChoiceCards when choices appear)
```

### Narrative Panel Script

```gdscript
# narrative_panel.gd
class_name NarrativePanel
extends PanelContainer

signal text_finished
signal choice_made(choice_id: String)

@export var text_speed := 25.0  # Characters per second, overridden by voice profile
@export var appear_duration := 0.12
@export var dismiss_duration := 0.06
@export var choice_stagger := 0.08  # Delay between each choice card appearing

@onready var portrait: TextureRect = %Portrait
@onready var speaker_name: Label = %SpeakerName
@onready var speaker_title: Label = %SpeakerTitle
@onready var text_area: RichTextLabel = %TextArea
@onready var choice_container: VBoxContainer = %ChoiceContainer

var _typing := false
var _skip_requested := false


func _ready() -> void:
	# Start hidden
	modulate.a = 0.0
	scale = Vector2(1.0, 0.9)
	pivot_offset = Vector2(size.x / 2.0, size.y)  # Scale from bottom edge
	hide()


func _unhandled_input(event: InputEvent) -> void:
	if not visible:
		return
	if event.is_action_pressed("ui_accept") or \
	   (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT):
		if _typing:
			_skip_requested = true
		get_viewport().set_input_as_handled()


func show_dialogue(speaker: Dictionary, bbcode_text: String,
		choices: Array[Dictionary] = []) -> void:
	# Setup speaker
	if speaker.has("portrait"):
		portrait.texture = speaker["portrait"]
		portrait.show()
	else:
		portrait.hide()

	speaker_name.text = speaker.get("name", "")
	speaker_title.text = speaker.get("title", "")
	speaker_title.visible = speaker.has("title")

	# Clear choices
	for child in choice_container.get_children():
		child.queue_free()

	# Setup text with bbcode
	text_area.text = ""
	text_area.bbcode_enabled = true

	# Appear animation — fast in
	show()
	var tw := create_tween().set_parallel(true)
	tw.tween_property(self, "modulate:a", 1.0, appear_duration)
	tw.tween_property(self, "scale",
		Vector2.ONE, appear_duration
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
	await tw.finished

	# Typewriter
	await _type_text(bbcode_text, speaker.get("text_speed", text_speed))

	text_finished.emit()

	# Show choices if any
	if not choices.is_empty():
		await _show_choices(choices)


func _type_text(bbcode_text: String, speed: float) -> void:
	text_area.text = bbcode_text
	text_area.visible_ratio = 0.0
	_typing = true
	_skip_requested = false

	var total_chars := text_area.get_total_character_count()
	if total_chars == 0:
		_typing = false
		return

	var char_time := 1.0 / speed

	while text_area.visible_ratio < 1.0:
		if _skip_requested:
			text_area.visible_ratio = 1.0
			break

		text_area.visible_ratio += 1.0 / float(total_chars)
		await get_tree().create_timer(char_time).timeout

	_typing = false


func _show_choices(choices: Array[Dictionary]) -> void:
	# Staggered reveal — cascade feel
	for i in choices.size():
		await get_tree().create_timer(choice_stagger).timeout

		var card := ChoiceCard.new()
		choice_container.add_child(card)
		card.setup(choices[i])

		# Slide in from right
		card.modulate.a = 0.0
		card.position.x += 20.0
		var tw := create_tween().set_parallel(true)
		tw.tween_property(card, "modulate:a", 1.0, 0.1)
		tw.tween_property(card, "position:x",
			card.position.x - 20.0, 0.15
		).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)

		card.card_selected.connect(func(c: ChoiceCard) -> void:
			choice_made.emit(c.choice_id)
		)


func dismiss() -> void:
	var tw := create_tween().set_parallel(true)
	tw.tween_property(self, "modulate:a", 0.0, dismiss_duration)
	tw.tween_property(self, "scale",
		Vector2(1.0, 0.95), dismiss_duration
	)
	await tw.finished
	hide()
```

### Portrait Expression Swap

Portraits should swap expressions **at the start of a new dialogue box, not mid-sentence**. The player's eye returns to the portrait at box transitions — that's when the change registers.

```gdscript
## Call BEFORE show_dialogue, not during typewriter
func set_expression(expression_texture: Texture2D) -> void:
	portrait.texture = expression_texture
	# No tween — instant swap reads as snappy. Tweening 2D portraits looks uncanny
```

Portrait sizes by layout:

| Layout | Portrait size | Notes |
|---|---|---|
| Bottom bar | 96x96 | Standard. Good balance of presence vs text space |
| Bottom bar (intimate) | 128x128 | Character-focused scenes. Less text per line but more presence |
| Side panel | 128x128 to 192x192 | Disco Elysium uses large portraits — they dominate the panel |
| Floating bubble | 48x48 | Minimal, just for speaker identification |

### Trap: Text Area Without Minimum Height

If the text area has no `custom_minimum_size.y`, short dialogue ("Yes.") and long dialogue ("Let me tell you about the ancient war that...") cause the panel to jump in height. Every height change is a layout reflow that pulls the player's eye away from the text.

Fix: `text_area.custom_minimum_size.y = 60` (fits ~3 lines at 13-14px). Panel height stays constant.

---

## 6. HUD Narratif Pattern

The persistent overlay during gameplay — minimal, functional, and most importantly: **it disappears when it should**.

### Elements

```
CanvasLayer (hud_layer, layer: 10)
 └─ Control (hud_root, PRESET_FULL_RECT)
     ├─ HBoxContainer (top_left, anchored top-left, offset 16px)
     │   ├─ TextureRect (character_portrait, 40x40)
     │   └─ VBoxContainer
     │       ├─ TextureProgressBar (health_bar)
     │       └─ TextureProgressBar (resource_bar — mana, stamina, etc.)
     ├─ VBoxContainer (top_right, anchored top-right, offset 16px)
     │   └─ Label (quest_hint, 12px, muted, max 40 chars)
     └─ VBoxContainer (notification_area, anchored right, vertical center)
         └─ [dynamic] toast notifications
```

### HUD Fade During Dialogue

The key principle: the HUD must vanish during narrative moments. Hades fades the run-info HUD during NPC conversations. Persona 5 replaces the entire HUD with the dialogue UI. The player should never see game-state numbers competing with narrative text.

```gdscript
# narrative_hud.gd
extends CanvasLayer

@export var fade_duration := 0.3
@export var hud_alpha_during_dialogue := 0.0  # Full hide. Use 0.15 for ghost-visible

@onready var hud_root: Control = $HudRoot

var _is_in_dialogue := false


func enter_dialogue() -> void:
	_is_in_dialogue = true
	var tw := create_tween()
	tw.tween_property(hud_root, "modulate:a",
		hud_alpha_during_dialogue, fade_duration
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)


func exit_dialogue() -> void:
	_is_in_dialogue = false
	var tw := create_tween()
	tw.tween_property(hud_root, "modulate:a",
		1.0, fade_duration * 0.6  # Faster restore — respect the player's return to gameplay
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
```

### Notification Toast System

Slide-in from edge, auto-dismiss, with a queue so they don't overlap. Same pattern as `collection_feel.md` toast system, but simpler — narrative toasts are text-only, no icons.

```gdscript
# notification_toast.gd — attach to the notification_area VBox
extends VBoxContainer

const MAX_VISIBLE := 3
const SLIDE_DISTANCE := 150.0
const APPEAR_DURATION := 0.25
const HOLD_DURATION := 3.0
const FADE_DURATION := 0.2

var _queue: Array[String] = []
var _active_count := 0


func show_notification(text: String) -> void:
	if _active_count >= MAX_VISIBLE:
		_queue.append(text)
		return
	_spawn_notification(text)


func _spawn_notification(text: String) -> void:
	_active_count += 1

	var label := Label.new()
	label.text = text
	label.add_theme_font_size_override("font_size", 13)
	label.add_theme_color_override("font_color", Color(0.9, 0.88, 0.8))  # Warm parchment white
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
	label.modulate.a = 0.0
	label.position.x = SLIDE_DISTANCE
	add_child(label)

	# Slide in
	var tw := create_tween()
	tw.set_parallel(true)
	tw.tween_property(label, "modulate:a", 1.0, APPEAR_DURATION * 0.5)
	tw.tween_property(label, "position:x", 0.0, APPEAR_DURATION)\
		.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)

	# Hold
	tw.chain()
	tw.tween_interval(HOLD_DURATION)

	# Fade out
	tw.tween_property(label, "modulate:a", 0.0, FADE_DURATION)

	# Cleanup
	tw.tween_callback(func() -> void:
		label.queue_free()
		_active_count -= 1
		_process_queue()
	)


func _process_queue() -> void:
	if _queue.is_empty() or _active_count >= MAX_VISIBLE:
		return
	_spawn_notification(_queue.pop_front())
```

### Reference: Hades Run Info

Hades shows the current boons/items as small icons at the top of the screen. During NPC dialogue, they fade to 0 alpha. The transition is seamless — the player never notices the HUD leaving, only that the conversation feels uncluttered.

### Reference: Disco Elysium Thought Cabinet

Disco Elysium's thought cabinet notifications slide in from the left with a unique sound and a brief text. They queue, they auto-dismiss, and they never overlap dialogue. The notification tells the player something changed without demanding attention.

---

## 7. Design Traps

Named traps with specific symptoms and fixes. Not generic warnings.

### Trap: Overloaded Choice Cards

**Symptom**: each choice card shows skill name, check percentage, cost, alignment shift, item preview, and a 3-line description. The player spends 30 seconds reading each card instead of making gut decisions.

**Reference**: Disco Elysium shows ONE metadata element per choice (the skill check). The description is one sentence. Hades shows the boon effect as a single highlighted line.

**Fix**: maximum 2 metadata chips per card. Title should be 5-8 words. Description should be one sentence. If you need more info, put it in a tooltip on hover — not on the card face.

### Trap: Status Effects Without Grouping

**Symptom**: the player has 12 status effects and the affliction grid is a wall of identical-sized icons. Buffs and debuffs are interleaved. The player can't quickly answer "am I in trouble?"

**Reference**: Darkest Dungeon puts debuffs (stress, diseases, quirks) in a separate panel from buffs. Slay the Spire at least color-codes them, but still suffers from the density problem late-game.

**Fix**: visual separator between negative and positive effects. Or two rows — debuffs top, buffs bottom. The question "am I in trouble?" should be answerable by glancing at one region.

### Trap: Portrait Aspect Ratio Mismatch

**Symptom**: portraits are drawn at 3:4 ratio but the TextureRect is square. Faces are squashed horizontally. Or drawn as squares but displayed in a taller rectangle — faces look elongated.

**Fix**: always use `STRETCH_KEEP_ASPECT_CENTERED`. Define the portrait region size, then draw within it. Never stretch to fill.

### Trap: Missing Empty States

**Symptom**: the equipment grid has 20 slots. The player has 2 items. 18 slots are identical gray rectangles with no visual distinction from "this slot doesn't exist."

**Fix**: every empty container needs a placeholder state.
- Equipment grid: "No items" centered text, or silhouette icons per slot type
- Quest log: "No active quests — explore the world" with a subtle decorative element
- Affliction grid: no affliction tiles visible = no grid visible (don't show an empty grid)
- Choice container: never empty during dialogue — if no choices, it simply doesn't render

### Trap: Panel Height Jumping on Short vs Long Text

**Symptom**: NPC says "Yes." — panel is 120px tall. Next line: "Let me tell you about the ancient prophecy that foretold the coming of a great warrior who would..." — panel snaps to 200px. The player's eye jumps every transition.

**Fix**: `custom_minimum_size.y` on the text area (60px = ~3 lines). Panel height is constant regardless of text length. Scroll for overflow (rare — break long text into multiple boxes).

---

## 8. Combination Stack

How everything combines for a dialogue-with-skill-checks scene. The complete hierarchy for a bottom-bar narrative panel with skill check choices.

### Full Node Hierarchy

```
CanvasLayer (narrative_layer, layer: 20)
 └─ NarrativePanel (narrative_panel.gd, PRESET_BOTTOM_WIDE)
     └─ MarginContainer (padding: 16)
         └─ VBoxContainer (separation: 8)
             ├─ HBoxContainer (speaker_row, separation: 12)
             │   ├─ TextureRect (portrait, 96x96, STRETCH_KEEP_ASPECT_CENTERED)
             │   └─ VBoxContainer
             │       ├─ Label (speaker_name, 14px bold)
             │       └─ Label (speaker_title, 11px, Color(0.65, 0.65, 0.7))
             ├─ RichTextLabel (text_area, min_height: 60, bbcode_enabled)
             └─ VBoxContainer (choice_container, separation: 6)
                 ├─ ChoiceCard
                 │   └─ MarginContainer (12px)
                 │       └─ HBoxContainer (separation: 10)
                 │           ├─ TextureRect (32x32 action icon)
                 │           └─ VBoxContainer
                 │               ├─ Label (title, 16px bold)
                 │               ├─ RichTextLabel (description, 13px)
                 │               └─ HBoxContainer (metadata, separation: 12)
                 │                   └─ StatChip ("[Rhetoric] Medium 52%")
                 ├─ ChoiceCard (choice 2)
                 └─ ChoiceCard (choice 3, locked state)
```

### Spacing and Token Values

These values work together. Changing one in isolation breaks the visual rhythm.

| Token | Value | Why |
|---|---|---|
| Panel bg opacity | 90% black (`Color(0.06, 0.06, 0.08, 0.92)`) | Dark enough to read text, light enough to see the game behind |
| Panel corner radius | 0px top, 0px bottom | Bottom bar = no rounded corners. Flush with screen edge |
| Panel padding | 16px all sides | Premium feel. 8px = cramped, 24px = wasted space |
| Speaker row gap | 12px | Portrait to name. Tight but not touching |
| Portrait to text gap | 8px (VBox separation) | Text area sits below speaker row, not beside portrait |
| Choice card gap | 6px | Dense enough to scan, loose enough to distinguish cards |
| Choice card padding | 12px | Inner margin. Matches panel padding feel |
| Choice card min height | 80px | Uniform across variable descriptions |
| Metadata chip gap | 12px | Chips within a card's metadata row |
| Text font size | 14px body, 16px choice title, 11px metadata | Hierarchy: title > body > meta |
| Accent color | `#e6a23c` (warm amber) | Used for: selected card border, speaker name, skill check label |
| Muted color | `Color(0.65, 0.65, 0.7)` | Used for: title subtitle, metadata labels, idle borders |

### Sequence: Scene Entry to Choice Resolution

```
1. Player triggers dialogue       → NarrativeHUD.enter_dialogue() (HUD fades, 0.3s)
2. Panel appears                  → NarrativePanel.show_dialogue() (scale 0.9→1.0, 0.12s)
3. Portrait + name set            → Instant, before text starts
4. Text types                     → visible_ratio 0→1, at voice profile speed (25 cps default)
5. Player can skip                → ui_accept → visible_ratio = 1.0 instantly
6. Text finishes                  → text_finished signal, 200ms pause
7. Choices stagger in             → Each card: 80ms delay, slide 20px from right, fade 0→1
8. Player hovers                  → Card scales 1.02, left accent border appears (100ms)
9. Player selects                 → Card pulses 1.04→1.01 (180ms), selected signal emits
10. Other cards fade              → Non-selected cards: modulate.a → 0.3 (150ms)
11. Panel dismisses               → modulate.a → 0, scale → 0.95 (60ms)
12. HUD restores                  → NarrativeHUD.exit_dialogue() (0.18s)
```

Total time from dialogue trigger to choices visible: ~1.5-3s depending on text length. Total time from choice selection to gameplay resume: ~0.3s. The asymmetry is intentional — slow to present (build the moment), fast to dismiss (respect the player's decision).

---

## Stack Final

```
1. Choice Cards          → PanelContainer, 5 states (idle/hover/selected/locked/skill_check),
                            80px min height, max 2 metadata chips, staggered reveal 80ms
2. Stat Chips            → HBox 24px tall, color-coded (green/red/neutral),
                            delta animation with ELASTIC ease, difficulty labels over raw %
3. Affliction Grid       → GridContainer max 8 visible, sorted negative-first,
                            expiring pulse, overflow "+N more" label
4. Equipment Grid        → 80x80 slots, rarity border colors, empty state silhouettes,
                            hover scale 1.05 BACK ease, equipped indicator top-right
5. Narrative Panel       → Bottom bar primary, 0.12s appear / 0.06s dismiss,
                            portrait 96x96 instant swap, text_area min_height 60px
6. HUD Narratif          → Fades to 0 during dialogue (0.3s), restores faster (0.18s),
                            notification toasts queue max 3, slide from right
7. Spacing tokens        → Panel 16px padding, 90% opacity, accent #e6a23c,
                            all values tested as a SET — don't change one in isolation
```

See also: `dialogue_presence.md` for portrait animation and voice blips, `collection_feel.md` for toast and inventory grid feel, `turn_combat_feel.md` for UI patterns during combat, `.claude/skills/godot-composition.md` for Godot Control node layout, `.claude/skills/godot-behavior.md` for UI states and feedback.
