# Collection & Inventory Feel

> **Purpose**: Recipe for Claude Code. The pickup chain, fly-to-UI, counter juice, rarity communication, and inventory reward-loop that make collecting items feel *good*. Values from Diablo, Hades, Slay the Spire, Stardew Valley, Hollow Knight, Pokemon. The marinade — not inventory system architecture.

---

## 1. The 4-Step Pickup Chain

Every satisfying pickup has exactly 4 beats. Skip one and the whole thing feels flat — the player's brain expects the full sequence because every polished game since Diablo has trained it.

```
Beat 1: CONFIRM       — SFX + VFX burst at pickup location (0.0s)
Beat 2: TRAVEL        — item flies from world to UI element (0.05-0.15s delay, then 0.3-0.5s flight)
Beat 3: ABSORB        — UI element reacts: flash, scale bump, particle burst (on arrival)
Beat 4: CELEBRATE     — counter number animates, optional toast notification (0.05-0.1s after absorb)
```

**Why each beat matters**:
- Skip beat 1 → player isn't sure they picked anything up
- Skip beat 2 → the connection between world and UI is broken, items feel "teleported"
- Skip beat 3 → the UI feels dead, like a spreadsheet updating
- Skip beat 4 → no closure, no dopamine, the loop doesn't complete

**Timing is sacred**: the total chain is 0.4-0.8s. Faster = arcade rush (Vampire Survivors). Slower = weighty treasure (Diablo). Never exceed 1.0s or the player is already doing something else mentally.

### Pickup Chain Controller

```gdscript
# pickup_chain.gd — autoload or attach to a manager node
extends Node

signal pickup_completed(item_id: String, amount: int)

## Beat 1: burst VFX scene (GPUParticles2D or AnimatedSprite2D)
@export var pickup_burst_scene: PackedScene
## Beat 2: the flying sprite scene
@export var fly_sprite_scene: PackedScene
## UI target — the icon/panel items fly toward
@export var ui_target: Control

## Timing
@export var fly_delay := 0.08           # Pause between burst and flight start
@export var fly_duration := 0.4         # Flight time (0.3 = snappy, 0.6 = floaty)
@export var fly_arc_height := 80.0      # Bezier control point offset (pixels)
@export var fly_start_scale := 1.0
@export var fly_end_scale := 0.3        # Shrinks during flight — "absorbed"

## Audio
@export var sfx_pickup: AudioStream     # Beat 1: short, bright, pitched
@export var sfx_absorb: AudioStream     # Beat 3: soft thud/chime on arrival

var _audio_player: AudioStreamPlayer


func _ready() -> void:
	_audio_player = AudioStreamPlayer.new()
	add_child(_audio_player)


## Call this from the collectible's body_entered or area_entered
func trigger_pickup(world_position: Vector2, icon_texture: Texture2D,
		item_id: String, amount: int = 1) -> void:
	# --- Beat 1: Confirm ---
	_play_sfx(sfx_pickup)
	_spawn_burst(world_position)

	# --- Beat 2: Travel (after short delay) ---
	await get_tree().create_timer(fly_delay).timeout
	_fly_to_ui(world_position, icon_texture, item_id, amount)


func _spawn_burst(world_pos: Vector2) -> void:
	if not pickup_burst_scene:
		return
	var burst := pickup_burst_scene.instantiate() as Node2D
	burst.global_position = world_pos
	get_tree().current_scene.add_child(burst)
	# Auto-free after particles finish
	if burst is GPUParticles2D:
		burst.emitting = true
		burst.finished.connect(burst.queue_free)
	else:
		# AnimatedSprite2D or similar — free after 0.5s
		get_tree().create_timer(0.5).timeout.connect(burst.queue_free)


func _fly_to_ui(world_pos: Vector2, icon_texture: Texture2D,
		item_id: String, amount: int) -> void:
	if not fly_sprite_scene or not ui_target:
		pickup_completed.emit(item_id, amount)
		return

	var flyer := fly_sprite_scene.instantiate() as Sprite2D
	# Place in CanvasLayer so it renders above everything
	var canvas := CanvasLayer.new()
	canvas.layer = 100
	get_tree().current_scene.add_child(canvas)
	canvas.add_child(flyer)

	flyer.texture = icon_texture
	flyer.scale = Vector2.ONE * fly_start_scale

	# Convert world position to screen position
	var cam := get_viewport().get_camera_2d()
	var start_screen: Vector2
	if cam:
		start_screen = world_pos - cam.get_screen_center_position() + get_viewport().get_visible_rect().size * 0.5
	else:
		start_screen = world_pos

	# UI target center in screen space
	var end_screen := ui_target.global_position + ui_target.size * 0.5

	flyer.position = start_screen

	# Bezier control point — arc upward, biased toward start
	var control := Vector2(
		lerp(start_screen.x, end_screen.x, 0.3),
		minf(start_screen.y, end_screen.y) - fly_arc_height
	)

	# Animate along quadratic bezier
	var tw := create_tween()
	tw.set_parallel(true)

	# Position: custom method_tween for bezier
	tw.tween_method(
		func(t: float) -> void:
			flyer.position = _quadratic_bezier(start_screen, control, end_screen, t),
		0.0, 1.0, fly_duration
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)

	# Scale: shrink during flight
	tw.tween_property(flyer, "scale",
		Vector2.ONE * fly_end_scale, fly_duration
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)

	# Slight rotation during flight (feels dynamic)
	tw.tween_property(flyer, "rotation",
		TAU * 0.5, fly_duration  # Half turn
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)

	tw.chain()

	# --- Beat 3 & 4: Absorb + Celebrate ---
	tw.tween_callback(func() -> void:
		_play_sfx(sfx_absorb)
		_absorb_effect()
		canvas.queue_free()
		pickup_completed.emit(item_id, amount)
	)


func _quadratic_bezier(p0: Vector2, p1: Vector2, p2: Vector2, t: float) -> Vector2:
	var q0 := p0.lerp(p1, t)
	var q1 := p1.lerp(p2, t)
	return q0.lerp(q1, t)


func _absorb_effect() -> void:
	if not ui_target:
		return
	# Scale bump: 1.0 -> 1.3 -> 1.0 with overshoot
	var tw := create_tween()
	tw.tween_property(ui_target, "scale",
		Vector2.ONE * 1.3, 0.08
	).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	tw.tween_property(ui_target, "scale",
		Vector2.ONE, 0.15
	).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)

	# White flash on the UI element
	if ui_target.has_method("set") and ui_target.get("modulate") != null:
		ui_target.modulate = Color(2.0, 2.0, 2.0)  # Overbright flash
		var tw2 := create_tween()
		tw2.tween_property(ui_target, "modulate",
			Color.WHITE, 0.2
		).set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)


func _play_sfx(stream: AudioStream) -> void:
	if stream and _audio_player:
		_audio_player.stream = stream
		_audio_player.play()
```

### Audio Pitch Escalation on Successive Pickups

Diablo, Hades, and Vampire Survivors all do this: picking up items in rapid succession raises the pitch of the pickup SFX. The escalation creates a "jackpot" feeling — the faster you collect, the more excited the audio gets.

```gdscript
# pitch_escalator.gd — attach to the pickup chain or use standalone
extends Node

@export var base_pitch := 1.0
@export var pitch_increment := 0.08    # Per successive pickup
@export var max_pitch := 1.6           # Cap before it sounds silly
@export var cooldown := 0.8            # Seconds before pitch resets
@export var decay_speed := 2.0         # How fast pitch drops back (per second)

var _current_pitch := 1.0
var _time_since_last := 999.0


func _process(delta: float) -> void:
	_time_since_last += delta
	if _time_since_last > cooldown:
		_current_pitch = move_toward(_current_pitch, base_pitch, decay_speed * delta)


func get_next_pitch() -> float:
	if _time_since_last < cooldown:
		_current_pitch = minf(_current_pitch + pitch_increment, max_pitch)
	else:
		_current_pitch = base_pitch

	_time_since_last = 0.0
	return _current_pitch
```

Use it: `audio_player.pitch_scale = pitch_escalator.get_next_pitch()` before playing the pickup SFX.

**Tested values**:
- **Coins/orbs (frequent)**: increment 0.05, max 1.4, cooldown 0.5s — subtle escalation
- **Items (moderate)**: increment 0.08, max 1.5, cooldown 0.8s — noticeable excitement
- **Rare drops (infrequent)**: no escalation — each one deserves its own full-weight SFX

---

## 2. Fly-to-UI Arc

The bezier arc is the connective tissue between world and UI. Without it, the pickup chain is "item disappears, UI updates" — with it, the player's eye is guided from action to information.

### Arc Tuning Values

| Game style | Duration | Arc height | End scale | Rotation |
|-----------|----------|------------|-----------|----------|
| Arcade (Vampire Survivors) | 0.25-0.35s | 40-60px | 0.2 | Full spin |
| Action RPG (Hades) | 0.35-0.45s | 60-90px | 0.3 | Half spin |
| Cozy (Stardew) | 0.45-0.60s | 80-120px | 0.4 | Gentle wobble |
| Weighty (Diablo) | 0.50-0.70s | 100-150px | 0.5 | Quarter turn |

**The control point bias**: the bezier control point should be biased toward the start position (30% of the way, not 50%). This creates a "launch" feeling — the item leaps up fast, then curves down into the UI element. 50% bias = lazy float. 30% bias = satisfying throw.

**Multi-item streams**: when 10+ items fly at once (coin shower), stagger the start times by 0.02-0.05s each and randomize the arc height +/- 20%. Identical arcs look robotic. Slight variation = organic river of rewards.

```gdscript
## Multi-item fly burst — call from pickup chain when amount > 1
func fly_burst(world_pos: Vector2, icon_texture: Texture2D,
		count: int, item_id: String) -> void:
	for i in count:
		var delay := i * 0.03 + randf() * 0.02
		var arc_offset := fly_arc_height * (0.8 + randf() * 0.4)
		get_tree().create_timer(delay).timeout.connect(
			func() -> void:
				_fly_to_ui_with_arc(world_pos, icon_texture, item_id, 1, arc_offset)
		)
```

---

## 3. UI Counter Animation

The difference between "number changes" and "number celebrates" is the difference between a dead UI and one that makes the player want to collect more.

### Counter Bump System

```gdscript
# counter_label.gd — attach to a Label node displaying a count
extends Label

@export var bump_scale := 1.3           # Peak scale during bump
@export var bump_duration := 0.25       # Total bump animation time
@export var color_flash := Color(1.0, 0.9, 0.3)  # Gold flash on increment
@export var milestone_shake := 5.0      # Shake intensity at milestones (every 10, 50, 100)
@export var count_up_speed := 0.02      # Seconds per digit when counting up

var _display_value := 0
var _target_value := 0
var _base_color: Color


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


func set_value(new_value: int, animate: bool = true) -> void:
	var old := _target_value
	_target_value = new_value

	if not animate or old == new_value:
		_display_value = new_value
		text = str(new_value)
		return

	# Count up animation (for large jumps)
	var diff := absi(new_value - old)
	if diff > 1:
		_animate_count_up(old, new_value)
	else:
		_display_value = new_value
		text = str(new_value)

	# Scale bump
	_bump()

	# Color flash
	_flash()

	# Milestone check
	if _is_milestone(new_value):
		_milestone_effect()


func _bump() -> void:
	var tw := create_tween()
	tw.tween_property(self, "scale",
		Vector2.ONE * bump_scale, bump_duration * 0.3
	).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	tw.tween_property(self, "scale",
		Vector2.ONE, bump_duration * 0.7
	).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)


func _flash() -> void:
	modulate = color_flash
	var tw := create_tween()
	tw.tween_property(self, "modulate",
		_base_color, 0.3
	).set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)


func _animate_count_up(from: int, to: int) -> void:
	var step := 1 if to > from else -1
	var tw := create_tween()
	var current := from
	while current != to:
		current += step
		var val := current  # Capture for lambda
		tw.tween_callback(func() -> void:
			_display_value = val
			text = str(val)
		)
		tw.tween_interval(count_up_speed)


func _milestone_effect() -> void:
	# Shake
	var original_pos := position
	var tw := create_tween()
	for i in 6:
		var offset := Vector2(
			randf_range(-milestone_shake, milestone_shake),
			randf_range(-milestone_shake, milestone_shake)
		)
		tw.tween_property(self, "position", original_pos + offset, 0.03)
	tw.tween_property(self, "position", original_pos, 0.05)

	# Bigger bump for milestones
	scale = Vector2.ONE * (bump_scale * 1.3)
	var tw2 := create_tween()
	tw2.tween_property(self, "scale",
		Vector2.ONE, 0.4
	).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)


func _is_milestone(value: int) -> bool:
	if value <= 0:
		return false
	if value % 100 == 0:
		return true
	if value % 50 == 0:
		return true
	if value % 10 == 0 and value <= 100:
		return true
	return false
```

**Counter feel by game type**:

| Game type | Bump scale | Flash color | Count-up | Milestone shakes |
|-----------|-----------|-------------|----------|-----------------|
| Arcade/frantic | 1.4 | White | Fast (0.01s) | Every 10 |
| RPG/Hades-style | 1.25 | Gold | Medium (0.02s) | Every 50 |
| Cozy/Stardew | 1.15 | Soft yellow | Slow (0.04s) | Every 100 |
| Horror/survival | 1.1 | Red | None (instant) | Never |

---

## 4. Toast Notification System

Item-acquired popups. The toast tells the player *what* they got, the pickup chain tells them *that* they got it.

```gdscript
# toast_manager.gd — autoload
extends CanvasLayer

const MAX_VISIBLE := 4
const APPEAR_DURATION := 0.3
const HOLD_DURATION := 2.0
const FADE_DURATION := 0.3
const STACK_OFFSET := 50.0      # Vertical spacing between toasts
const SLIDE_IN_OFFSET := 200.0  # Horizontal slide distance

@export var toast_scene: PackedScene  # Scene with: HBoxContainer > [TextureRect, Label]

var _active_toasts: Array[Control] = []
var _queue: Array[Dictionary] = []


func show_toast(icon: Texture2D, item_name: String,
		rarity_color: Color = Color.WHITE, amount: int = 1) -> void:
	var data := {
		"icon": icon,
		"name": item_name,
		"color": rarity_color,
		"amount": amount
	}

	if _active_toasts.size() >= MAX_VISIBLE:
		_queue.append(data)
		return

	_spawn_toast(data)


func _spawn_toast(data: Dictionary) -> void:
	var toast: Control
	if toast_scene:
		toast = toast_scene.instantiate() as Control
	else:
		toast = _create_default_toast()

	add_child(toast)

	# Configure content
	var icon_rect := toast.find_child("Icon") as TextureRect
	if icon_rect and data.get("icon"):
		icon_rect.texture = data["icon"]

	var label := toast.find_child("Label") as Label
	if label:
		var amount_text := " x%d" % data["amount"] if data["amount"] > 1 else ""
		label.text = "%s%s" % [data["name"], amount_text]
		label.add_theme_color_override("font_color", data["color"])

	# Position: top-right, stacked
	var viewport_size := get_viewport().get_visible_rect().size
	var target_x := viewport_size.x - toast.size.x - 20.0
	var target_y := 20.0 + _active_toasts.size() * STACK_OFFSET

	toast.position = Vector2(target_x + SLIDE_IN_OFFSET, target_y)
	toast.modulate.a = 0.0

	_active_toasts.append(toast)

	# Appear: slide in + fade in
	var tw := create_tween()
	tw.set_parallel(true)
	tw.tween_property(toast, "position:x", target_x, APPEAR_DURATION)\
		.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	tw.tween_property(toast, "modulate:a", 1.0, APPEAR_DURATION * 0.5)

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

	# Fade out: slide right + fade
	tw.tween_property(toast, "position:x",
		target_x + SLIDE_IN_OFFSET * 0.5, FADE_DURATION
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
	tw.tween_property(toast, "modulate:a", 0.0, FADE_DURATION).set_parallel(true)

	# Cleanup
	tw.tween_callback(func() -> void:
		_active_toasts.erase(toast)
		toast.queue_free()
		_reposition_toasts()
		_process_queue()
	)


func _reposition_toasts() -> void:
	for i in _active_toasts.size():
		var target_y := 20.0 + i * STACK_OFFSET
		var tw := create_tween()
		tw.tween_property(_active_toasts[i], "position:y",
			target_y, 0.2
		).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)


func _process_queue() -> void:
	if _queue.is_empty() or _active_toasts.size() >= MAX_VISIBLE:
		return
	_spawn_toast(_queue.pop_front())


func _create_default_toast() -> Control:
	var panel := PanelContainer.new()
	panel.custom_minimum_size = Vector2(250, 40)

	var hbox := HBoxContainer.new()
	hbox.add_theme_constant_override("separation", 8)
	panel.add_child(hbox)

	var icon := TextureRect.new()
	icon.name = "Icon"
	icon.custom_minimum_size = Vector2(32, 32)
	icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	hbox.add_child(icon)

	var label := Label.new()
	label.name = "Label"
	label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	hbox.add_child(label)

	return panel
```

**Toast timing by context**:

| Context | Appear | Hold | Fade | Feel |
|---------|--------|------|------|------|
| Common item pickup | 0.2s | 1.5s | 0.2s | Quick, don't clutter |
| Rare/epic drop | 0.4s | 3.0s | 0.4s | Linger, savor the moment |
| Achievement unlock | 0.5s | 4.0s | 0.5s | Ceremonial, let them read |
| Quest progress | 0.3s | 2.0s | 0.3s | Informational, not celebration |

---

## 5. Rarity Communication

Rarity isn't just a color on a name tag. It's a full-spectrum visual language that trains the player to feel excitement before they even read the item name.

### The WoW Standard (Color Tiers)

Established by Diablo (1996), codified by World of Warcraft, now hardwired into players' brains:

| Tier | Hex | Color() | Glow | Particles | Sound |
|------|-----|---------|------|-----------|-------|
| Common | `#9D9D9D` | `Color(0.62, 0.62, 0.62)` | None | None | Soft click |
| Uncommon | `#1EFF00` | `Color(0.12, 1.0, 0.0)` | Subtle pulse (0.8-1.0 alpha, 2s cycle) | None | Gentle chime |
| Rare | `#0070FF` | `Color(0.0, 0.44, 1.0)` | Steady glow | Faint sparkles (2-3/s) | Clear bell |
| Epic | `#A335EE` | `Color(0.64, 0.21, 0.93)` | Pulsing glow (0.6-1.0 alpha, 1.5s) | Particle burst on pickup | Rising chord |
| Legendary | `#FF8000` | `Color(1.0, 0.50, 0.0)` | Strong glow + rays | Continuous particles + screen flash | Dramatic fanfare |

### Rarity Glow Shader

```gdshader
shader_type canvas_item;

uniform vec4 glow_color : source_color = vec4(1.0, 0.5, 0.0, 1.0);
uniform float glow_intensity : hint_range(0.0, 3.0) = 1.0;
uniform float pulse_speed : hint_range(0.0, 5.0) = 1.5;
uniform float pulse_range : hint_range(0.0, 1.0) = 0.3;  // 0 = steady, 0.3 = gentle pulse
uniform float glow_size : hint_range(0.0, 10.0) = 2.0;    // Outline thickness in pixels

void fragment() {
	vec4 tex = texture(TEXTURE, UV);

	// Sample neighbors for outline detection
	float outline = 0.0;
	vec2 pixel = TEXTURE_PIXEL_SIZE * glow_size;

	for (float x = -1.0; x <= 1.0; x += 1.0) {
		for (float y = -1.0; y <= 1.0; y += 1.0) {
			if (x == 0.0 && y == 0.0) continue;
			outline += texture(TEXTURE, UV + vec2(x, y) * pixel).a;
		}
	}

	// Glow on transparent pixels near opaque ones
	float pulse = 1.0 - pulse_range + pulse_range * sin(TIME * pulse_speed);
	float glow_alpha = clamp(outline, 0.0, 1.0) * (1.0 - tex.a) * glow_intensity * pulse;

	vec4 result = tex;
	result.rgb = mix(glow_color.rgb, tex.rgb, tex.a);
	result.a = max(tex.a, glow_alpha);

	COLOR = result;
}
```

**Rarity shader values**:

| Tier | glow_intensity | pulse_speed | pulse_range | glow_size |
|------|---------------|-------------|-------------|-----------|
| Common | 0.0 | 0.0 | 0.0 | 0.0 |
| Uncommon | 0.4 | 1.0 | 0.15 | 1.5 |
| Rare | 0.8 | 1.5 | 0.2 | 2.0 |
| Epic | 1.2 | 2.0 | 0.3 | 2.5 |
| Legendary | 2.0 | 2.5 | 0.4 | 3.5 |

### The Moment of Reveal

The most important fraction of a second in loot games: the instant the player sees the rarity. Diablo makes this a physical event — the item drops, the nameplate color is visible mid-air, and the sound plays based on rarity. The player's brain reads the color before the name.

**Drop reveal sequence (Diablo-style)**:
1. Item entity appears at drop point with a small upward bounce (0.2s)
2. Nameplate fades in during the bounce — color is the FIRST thing visible
3. Rarity-appropriate glow activates on landing
4. If Epic+: brief screen flash (0.05s, white, alpha 0.15) + particles radiate from drop point
5. If Legendary: camera micro-shake (intensity 2px, duration 0.15s) + unique SFX

---

## 6. Grid Inventory Feel

A grid inventory is either a boring storage locker or a satisfying tactile experience. The difference is in the micro-interactions.

### Hover + Drag + Snap

```gdscript
# inventory_slot.gd — attach to each slot (TextureRect or Panel)
extends Control

signal item_drag_started(slot: Control)
signal item_dropped(from_slot: Control, to_slot: Control)

@export var hover_scale := 1.05
@export var hover_brightness := 1.2
@export var snap_duration := 0.12         # Snap-to-slot animation
@export var drag_scale := 1.1             # Slightly bigger while dragging
@export var drag_alpha := 0.7             # Ghost effect while dragging

var _is_hovered := false
var _is_dragging := false
var _drag_offset := Vector2.ZERO
var _original_position := Vector2.ZERO


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


func _on_hover_enter() -> void:
	_is_hovered = true
	var tw := create_tween().set_parallel(true)
	tw.tween_property(self, "scale", Vector2.ONE * hover_scale, 0.1)\
		.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	tw.tween_property(self, "modulate",
		Color(hover_brightness, hover_brightness, hover_brightness), 0.1)


func _on_hover_exit() -> void:
	_is_hovered = false
	if _is_dragging:
		return
	var tw := create_tween().set_parallel(true)
	tw.tween_property(self, "scale", Vector2.ONE, 0.1)
	tw.tween_property(self, "modulate", Color.WHITE, 0.1)


func start_drag() -> void:
	_is_dragging = true
	_original_position = position
	z_index = 100  # Render above everything
	scale = Vector2.ONE * drag_scale
	modulate.a = drag_alpha
	item_drag_started.emit(self)


func drop_on(target_slot: Control) -> void:
	_is_dragging = false
	z_index = 0
	modulate.a = 1.0

	# Snap animation — the FEEL of placing an item
	var tw := create_tween().set_parallel(true)
	tw.tween_property(self, "global_position",
		target_slot.global_position, snap_duration
	).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)  # Back = tiny overshoot = satisfying
	tw.tween_property(self, "scale", Vector2.ONE, snap_duration)

	item_dropped.emit(self, target_slot)


func cancel_drag() -> void:
	_is_dragging = false
	z_index = 0
	modulate.a = 1.0

	# Rubber-band back to original position
	var tw := create_tween()
	tw.tween_property(self, "global_position",
		_original_position, 0.2
	).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
	tw.tween_property(self, "scale", Vector2.ONE, 0.15).set_parallel(true)
```

**Key feel details**:
- **Snap uses TRANS_BACK** (slight overshoot) not TRANS_LINEAR. Linear snap = clinical. Back ease = physical weight.
- **Cancel drag uses TRANS_ELASTIC** (rubber band). The item bounces back like it's attached by a string.
- **Drag alpha = 0.7**, not 0.5. Too transparent = ghostly/broken. Slightly transparent = "I'm holding this."
- **Hover scale = 1.05**, not 1.2. Inventory is dense — big scale hover overlaps neighbors and looks janky.

### Sort Animation Cascade

When the player hits "sort," items shouldn't teleport. They should cascade into position like falling dominoes:

```gdscript
## Sort animation — call after rearranging item data
func animate_sort(slots: Array[Control], stagger: float = 0.03) -> void:
	for i in slots.size():
		var slot := slots[i]
		# Items fly in from a gathered center point
		var center := size / 2.0
		slot.modulate.a = 0.0
		slot.scale = Vector2.ONE * 0.5
		slot.position = center  # All start from center

		var tw := create_tween().set_parallel(true)
		tw.set_delay(i * stagger)  # Stagger = cascade
		tw.tween_property(slot, "position", slot.get_meta("target_pos"), 0.25)\
			.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
		tw.tween_property(slot, "modulate:a", 1.0, 0.15)
		tw.tween_property(slot, "scale", Vector2.ONE, 0.2)\
			.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
```

---

## 7. The Collection Dopamine Loop

Why does picking stuff up feel good? Because the best loot games engineer a closed sensory loop:

**Action** (press button / walk over item) → **Confirm** (SFX + VFX) → **Travel** (fly-to-UI) → **Absorb** (UI reacts) → **Celebrate** (counter, toast) → **Desire** (see number go up, want more) → **Action** (repeat)

### What each game does differently:

**Diablo** — the loop is weighted toward the *reveal*. The item drops, the color tells you the rarity, and the sound plays BEFORE you pick it up. The dopamine starts at the drop, not the pickup. Your brain is already excited when you click.

**Hades** — the loop is weighted toward *velocity*. Items vacuum toward you, the pickup chain is fast (0.3s total), and boons appear with a character portrait + voice line that personalizes the reward. Speed + personality.

**Slay the Spire** — the loop is weighted toward *choice*. The card reward screen pauses the game, presents 3 options with full art, and the selection animation (card enlarges, others fade) makes the choice feel consequential. Slower loop, but each cycle matters more.

**Stardew Valley** — the loop is weighted toward *accumulation*. Individual pickups are modest (short SFX, small icon), but the museum collection, shipping bin totals, and end-of-day summary transform hundreds of small loops into one big celebration. Delayed gratification.

**Hollow Knight** — the loop is weighted toward *scarcity*. Geo pickups are frequent but low-key. Charm acquisitions are rare and have a unique jingle + full-screen notification. The rarity makes each charm feel earned.

### Inventory as Reward

The inventory screen should feel like opening a treasure chest, not reading a spreadsheet:

- **Pokemon** — creatures displayed with animations, cries on selection, stat screens feel like Pokedex entries. Browsing IS gameplay.
- **Stardew Valley museum** — physical placement of donated items. The player arranges their collection spatially. Each new donation = visible progress.
- **Hades Codex** — lore entries unlock with character portraits and voice. Reading the codex feels like deepening relationships, not checking a log.

**The test**: if the player voluntarily opens the inventory to browse (not to use an item), the inventory is a reward. If they only open it when they need something, it's just storage.

---

## 8. Combining Techniques (Full Stack)

### Stack: "Rare Item Pickup"

Total duration: ~0.8s from pickup to celebration.

1. **Beat 1** (0.0s): Pickup SFX (pitched chime), burst particles (8-12 blue sparkles, 0.3s lifetime)
2. **Beat 2** (0.08s): Item icon flies to UI — bezier arc, 0.4s flight, shrinks 1.0→0.3, half rotation
3. **Beat 3** (0.48s): UI icon flashes white (0.05s) then pulses blue glow, scale bump 1.0→1.3→1.0 (BACK+ELASTIC)
4. **Beat 4** (0.55s): Counter bumps, toast slides in from right ("Rare Gem x1" in blue `#0070FF`), holds 2.5s
5. **Ambient**: rarity glow shader on ground loot before pickup (intensity 0.8, pulse 1.5Hz)

### Stack: "Legendary Drop Reveal"

Total duration: ~1.5s. This is an *event*, not just a pickup.

1. **Drop** (0.0s): Item entity spawns with upward bounce (0.2s, 30px height)
2. **Flash** (0.1s): Screen flash white alpha 0.15, camera micro-shake 2px for 0.15s
3. **Audio** (0.1s): Dramatic SFX — rising sweep into impact chord, no pitch escalation
4. **Glow** (0.3s): Legendary glow shader activates (intensity 2.0, pulse 2.5Hz, glow_size 3.5)
5. **Particles** (0.3s): Continuous golden sparks radiate from item, 20-30 particles/sec
6. **Nameplate** (0.4s): Item name fades in, orange `#FF8000`, slightly larger font than normal
7. **Pickup** (player-triggered): full 4-beat chain but with 0.5s flight (heavier feel), bigger UI bump (1.4x), toast holds 4.0s

### Stack: "End-of-Level Loot Summary"

The Stardew Valley / Hades end screen. Delayed gratification payoff.

1. **Transition** (0.5s): gameplay fades, summary panel slides up from bottom
2. **Items cascade** (staggered 0.08s each): each item icon appears with a small bounce + SFX, pitch escalating
3. **Totals count up** (0.02s per digit): gold counter counts up with bump animation, each digit click is audible
4. **Rare items last** (0.5s pause before): rare+ items appear with their glow shader and a unique SFX per tier
5. **Grand total** (0.3s): final number appears with large bump (1.5x) + screen flash + celebratory SFX

---

## Gotchas

1. **Fly-to-UI needs CanvasLayer** — flying sprites in world space get culled by camera bounds. Always parent to a CanvasLayer with high layer index (100+), or the item disappears mid-flight when the camera moves

2. **World-to-screen coordinate conversion** — `get_viewport().get_camera_2d().get_screen_center_position()` gives the camera center. UI elements use screen coordinates. The conversion: `screen_pos = world_pos - cam_center + viewport_size * 0.5`. Forgetting this = items fly to wrong positions

3. **Tween on freed node = error** — if the player changes scene while a fly-to-UI tween is running, the tween callbacks reference freed nodes. Always `tw.kill()` in `_exit_tree()` or use `is_instance_valid()` checks

4. **UI scale / pivot_offset** — `scale` on Control nodes scales from top-left by default, not center. Always set `pivot_offset = size / 2.0` or the bump animation looks wrong (grows toward bottom-right)

5. **AudioStreamPlayer vs AudioStreamPlayer2D for pickups** — use non-positional `AudioStreamPlayer` for the SFX. The pickup is a UI event, not a world event. Positional audio on pickups feels wrong when the item is close to the camera edge

6. **Pitch escalation overflow** — AudioStreamPlayer.pitch_scale above 2.0 sounds like chipmunks. Cap at 1.5-1.6 for musical pickups. Above that, raise volume slightly instead of pitch

7. **Toast stacking with rapid pickups** — if the player picks up 20 items in 1 second, 20 toasts will queue and display one by one for 40+ seconds. Cap visible toasts (4 max), batch identical items ("Gold Coin x15" not fifteen "Gold Coin x1"), and use a "and X more..." fallback

8. **Rarity glow on pixel art** — the outline-based glow shader samples neighboring pixels. At pixel-art scales (16x16 sprites), glow_size > 2.0 bleeds far beyond the sprite. Keep glow_size at 1.0-1.5 for pixel art, 2.0-3.5 for HD sprites

9. **Counter count-up speed** — counting from 0 to 999 at 0.02s per digit = 20 seconds. For large jumps, use logarithmic speed (fast at start, slow at end) or skip to the target with a single bump. Never make the player wait for a counter to finish

10. **Drag-and-drop on touchscreen** — long-press to initiate drag (0.3s hold threshold), not immediate. On desktop, mouse_down = immediate drag. Platform detection matters for inventory feel
