# Turn-Based Combat Feel

> **Purpose**: Recipe for Claude Code. The anticipation-action-reaction beat, camera choreography, damage numbers, turn transitions, and the Persona 5 principle that make turn-based combat feel *alive*. Values from Persona 5, Chrono Trigger, Darkest Dungeon, Paper Mario, Octopath Traveler, Clair Obscur. The marinade — not combat system architecture.

---

## 1. The 3-Phase Action Beat

Every turn-based action has 3 phases. This is the atomic unit of combat feel. Get the timing between these phases right and combat feels cinematic. Get it wrong and it feels like a slideshow.

```
Phase 1: ANTICIPATION  — wind-up, camera zooms to attacker (0.2-0.4s)
Phase 2: ACTION        — the hit/spell/move itself (0.1-0.3s)
Phase 3: REACTION      — impact on target, camera shakes, numbers pop (0.3-0.5s)
```

**Total beat: 0.6-1.2s per action.** This is the Persona 5 discovery — they don't make combat faster, they make each beat *tighter*. The dead time between beats is what kills pacing, not the beats themselves.

**Why each phase matters**:
- Skip anticipation → the action has no weight, it just "happens"
- Skip action → the player doesn't see what they chose (rare, but sped-up animations do this)
- Skip reaction → no impact, no satisfaction, the target might as well be a health bar decrementing

### Action Beat Controller

```gdscript
# action_beat.gd — orchestrates a single combat action's 3 phases
class_name ActionBeat
extends RefCounted

signal beat_completed

var _scene_tree: SceneTree
var _camera: CombatCamera  # See section 2


func _init(tree: SceneTree, camera: CombatCamera) -> void:
	_scene_tree = tree
	_camera = camera


## Execute a full 3-phase action beat
func execute(attacker: Node2D, target: Node2D, action_data: Dictionary) -> void:
	# --- Phase 1: Anticipation ---
	await _anticipation(attacker, action_data)

	# --- Phase 2: Action ---
	await _action(attacker, target, action_data)

	# --- Phase 3: Reaction ---
	await _reaction(target, action_data)

	beat_completed.emit()


func _anticipation(attacker: Node2D, data: Dictionary) -> void:
	var duration: float = data.get("wind_up_duration", 0.3)

	# Camera: zoom toward attacker
	_camera.focus_on(attacker.global_position, 1.15, duration)

	# Attacker wind-up animation
	if attacker.has_method("play_wind_up"):
		attacker.play_wind_up(data.get("action_name", "attack"))

	# Brief pause at peak of anticipation — the "held breath"
	await _scene_tree.create_timer(duration).timeout


func _action(attacker: Node2D, target: Node2D, data: Dictionary) -> void:
	var duration: float = data.get("action_duration", 0.15)

	# Camera: snap pan toward target (fast, not tweened)
	_camera.pan_to(target.global_position, duration * 0.5)

	# Attacker lunges or plays action animation
	if attacker.has_method("play_action"):
		attacker.play_action(data.get("action_name", "attack"))

	await _scene_tree.create_timer(duration).timeout


func _reaction(target: Node2D, data: Dictionary) -> void:
	var reaction_duration: float = data.get("reaction_duration", 0.4)
	var damage: int = data.get("damage", 0)
	var damage_type: String = data.get("damage_type", "physical")
	var is_critical: bool = data.get("is_critical", false)

	# Camera: shake on impact
	var shake_intensity := 4.0 if not is_critical else 10.0
	var shake_duration := 0.15 if not is_critical else 0.25
	_camera.shake(shake_intensity, shake_duration)

	# Brief freeze frame — the Persona 5 trick
	# Everything pauses for 0.03-0.06s on impact. Barely perceptible but FELT.
	if is_critical:
		Engine.time_scale = 0.05
		await _scene_tree.create_timer(0.03 * (1.0 / 0.05)).timeout  # Real time, not game time
		Engine.time_scale = 1.0

	# Target hit reaction
	if target.has_method("play_hit_reaction"):
		target.play_hit_reaction(is_critical)

	# Damage number popup
	if damage > 0:
		_spawn_damage_number(target, damage, damage_type, is_critical)

	# Camera: ease back to neutral
	_camera.ease_to_neutral(reaction_duration * 0.5)

	await _scene_tree.create_timer(reaction_duration).timeout


func _spawn_damage_number(target: Node2D, damage: int, type: String, critical: bool) -> void:
	# Delegates to DamageNumber system (section 3)
	var popup := DamageNumber.create(damage, type, critical)
	target.add_child(popup)
	popup.position = Vector2(0, -40)  # Above the target's head
```

### Beat Timing by Action Type

| Action | Anticipation | Action | Reaction | Total | Feel |
|--------|-------------|--------|----------|-------|------|
| Basic attack | 0.2s | 0.1s | 0.3s | 0.6s | Quick, snappy |
| Skill/spell | 0.35s | 0.2s | 0.4s | 0.95s | Weighty, deliberate |
| Critical hit | 0.2s | 0.1s | 0.5s | 0.8s | Extended reaction sells the impact |
| Ultimate/limit | 0.5s | 0.3s | 0.6s | 1.4s | Cinematic, don't rush |
| Heal/buff | 0.25s | 0.3s | 0.2s | 0.75s | Longer action (casting), short reaction |
| Item use | 0.15s | 0.2s | 0.15s | 0.5s | Fastest — items shouldn't feel epic |

**The overlap trick (Persona 5's secret)**: phase 2 doesn't wait for phase 1 to fully complete. Start the camera pan to target at 80% of anticipation. Start the damage number at 90% of action. This 10-20% overlap is imperceptible but makes combat feel 30% faster.

---

## 2. Combat Camera

The camera is the invisible director. In real-time combat, the player controls the camera. In turn-based, the game directs it like a film.

### Camera Controller

```gdscript
# combat_camera.gd — attach to Camera2D used in battle
class_name CombatCamera
extends Camera2D

## Base position (center of the battlefield)
@export var neutral_position := Vector2.ZERO
@export var neutral_zoom := Vector2(1.0, 1.0)

## Shake
@export var shake_decay := 5.0    # How fast shake dies (higher = faster)
@export var max_shake_offset := 20.0

## Focus
@export var focus_zoom_default := Vector2(1.15, 1.15)   # Zoom on attacker
@export var close_up_zoom := Vector2(1.4, 1.4)          # Dramatic close-up

var _shake_intensity := 0.0
var _shake_time := 0.0
var _base_offset := Vector2.ZERO
var _current_tween: Tween


func _process(delta: float) -> void:
	# Apply shake on top of tweened position
	if _shake_intensity > 0.0:
		_shake_time += delta
		_shake_intensity = move_toward(_shake_intensity, 0.0, shake_decay * delta * _shake_intensity)
		var shake_offset := Vector2(
			randf_range(-_shake_intensity, _shake_intensity),
			randf_range(-_shake_intensity, _shake_intensity)
		)
		offset = _base_offset + shake_offset
	else:
		offset = _base_offset


## Zoom + pan toward a position (anticipation phase)
func focus_on(target_pos: Vector2, zoom_factor: float = 1.15,
		duration: float = 0.3) -> void:
	_kill_tween()
	var target_zoom := neutral_zoom * zoom_factor

	# Don't center exactly on target — offset 30% toward center
	# This keeps battlefield context visible
	var focus_pos := neutral_position.lerp(target_pos, 0.6)

	_current_tween = create_tween().set_parallel(true)
	_current_tween.tween_property(self, "global_position",
		focus_pos, duration
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
	_current_tween.tween_property(self, "zoom",
		target_zoom, duration
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)


## Fast pan to a new position (action phase — attacker to target)
func pan_to(target_pos: Vector2, duration: float = 0.1) -> void:
	_kill_tween()
	var focus_pos := neutral_position.lerp(target_pos, 0.5)

	_current_tween = create_tween()
	_current_tween.tween_property(self, "global_position",
		focus_pos, duration
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)


## Camera shake (reaction phase — impact)
func shake(intensity: float = 5.0, duration: float = 0.15) -> void:
	_shake_intensity = clampf(intensity, 0.0, max_shake_offset)
	_shake_time = 0.0
	# Auto-decay handles the rest in _process


## Return to neutral position and zoom (resolution)
func ease_to_neutral(duration: float = 0.4) -> void:
	_kill_tween()
	_current_tween = create_tween().set_parallel(true)
	_current_tween.tween_property(self, "global_position",
		neutral_position, duration
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT)
	_current_tween.tween_property(self, "zoom",
		neutral_zoom, duration
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT)


## Dramatic close-up (ultimates, finishing blows)
func close_up(target_pos: Vector2, duration: float = 0.4) -> void:
	_kill_tween()
	_current_tween = create_tween().set_parallel(true)
	_current_tween.tween_property(self, "global_position",
		target_pos, duration
	).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
	_current_tween.tween_property(self, "zoom",
		close_up_zoom, duration
	).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)


func _kill_tween() -> void:
	if _current_tween and _current_tween.is_valid():
		_current_tween.kill()
```

### Camera Choreography per Action Type

| Action | Anticipation cam | Action cam | Reaction cam |
|--------|-----------------|------------|--------------|
| Basic attack | Zoom 1.1x on attacker, 0.2s | Fast pan to target, 0.08s | Shake 4px / 0.1s, ease neutral 0.3s |
| Heavy attack | Zoom 1.2x on attacker, 0.3s | Pan to target, 0.12s | Shake 8px / 0.2s, ease neutral 0.4s |
| Spell (ranged) | Zoom 1.15x on caster, 0.3s | Hold on caster (effect travels), 0.2s | Zoom 1.1x on target, shake 5px, ease neutral 0.5s |
| Ultimate | Close-up 1.4x on attacker, 0.5s | Rapid cut to target (no pan), instant | Heavy shake 12px / 0.3s, slow ease neutral 0.6s |
| Heal | Gentle zoom 1.05x on target, 0.25s | Hold (particles rise), 0.3s | No shake — soft ease neutral 0.3s |
| Miss/dodge | Zoom 1.1x on attacker, 0.2s | Pan to target, 0.1s | No shake — target slides aside 0.2s, ease neutral 0.2s |

**Camera distance = emotion**: Darkest Dungeon puts you uncomfortably close (side-view, characters fill the screen). Final Fantasy puts you at a distance (wide battlefield). Close = intimate, stressful. Far = strategic, detached. Match camera distance to the emotional register of the game.

---

## 3. Damage Numbers

The pop direction, scale curve, color, and duration of damage numbers communicate more than the number itself.

### Damage Number Scene

```gdscript
# damage_number.gd — root of a standalone scene (Label or RichTextLabel)
class_name DamageNumber
extends Label

## Configuration
@export var rise_distance := 40.0        # How far the number floats up
@export var drift_range := 15.0          # Random horizontal drift
@export var duration := 0.8              # Total lifetime
@export var scale_start := 1.8           # Start big
@export var scale_peak := 1.0            # Settle to normal
@export var critical_scale_start := 2.5
@export var critical_shake := 3.0

## Color by damage type
var _type_colors := {
	"physical": Color(1.0, 1.0, 1.0),           # White
	"fire": Color(1.0, 0.3, 0.0),               # Orange-red
	"ice": Color(0.0, 0.75, 1.0),               # Cyan
	"lightning": Color(1.0, 0.84, 0.0),          # Gold
	"poison": Color(0.2, 0.8, 0.2),             # Green
	"holy": Color(1.0, 0.98, 0.8),              # Warm white
	"dark": Color(0.5, 0.0, 0.8),               # Purple
	"heal": Color(0.2, 1.0, 0.4),               # Bright green
	"miss": Color(0.6, 0.6, 0.6),               # Gray
}

## Critical hit color
var _critical_color := Color(1.0, 0.9, 0.2)  # Bright gold-yellow


static func create(damage: int, type: String = "physical",
		critical: bool = false) -> DamageNumber:
	var instance := DamageNumber.new()
	instance._setup(damage, type, critical)
	return instance


func _setup(damage: int, type: String, critical: bool) -> void:
	# Text
	if type == "miss":
		text = "MISS"
	else:
		text = str(damage)
		if critical:
			text = str(damage) + "!"

	# Font size
	add_theme_font_size_override("font_size", 24 if not critical else 32)

	# Color
	if critical:
		add_theme_color_override("font_color", _critical_color)
	elif _type_colors.has(type):
		add_theme_color_override("font_color", _type_colors[type])

	# Outline for readability
	add_theme_constant_override("outline_size", 3)
	add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))

	# Center text
	horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	pivot_offset = size / 2.0


func _ready() -> void:
	var is_critical := text.ends_with("!")
	var drift_x := randf_range(-drift_range, drift_range)

	# Initial scale: start big, shrink to normal
	var start_scale := critical_scale_start if is_critical else scale_start
	scale = Vector2.ONE * start_scale

	var tw := create_tween().set_parallel(true)

	# Rise upward with slight drift
	tw.tween_property(self, "position",
		position + Vector2(drift_x, -rise_distance), duration
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)

	# Scale: big -> normal -> slightly smaller
	tw.tween_property(self, "scale",
		Vector2.ONE * scale_peak, duration * 0.3
	).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)

	# Fade out in the last 40%
	tw.tween_property(self, "modulate:a",
		0.0, duration * 0.4
	).set_delay(duration * 0.6)

	# Critical: add shake
	if is_critical:
		_critical_shake_effect()

	# Auto-free
	tw.chain()
	tw.tween_callback(queue_free)


func _critical_shake_effect() -> void:
	var tw := create_tween()
	var base_pos := position
	for i in 5:
		tw.tween_property(self, "position",
			base_pos + Vector2(randf_range(-critical_shake, critical_shake),
				randf_range(-critical_shake, critical_shake)),
			0.03
		)
	tw.tween_property(self, "position", base_pos, 0.02)
```

### Damage Number Tuning

| Game style | Rise dist | Duration | Scale start | Drift | Font size |
|-----------|-----------|----------|-------------|-------|-----------|
| Snappy (Persona) | 30px | 0.6s | 1.5 | 10px | 20 |
| Weighty (Darkest Dungeon) | 20px | 1.0s | 2.0 | 5px | 28 |
| Playful (Paper Mario) | 50px | 0.8s | 2.2 | 20px | 32 |
| Restrained (Octopath) | 35px | 0.7s | 1.3 | 8px | 18 |

**The readability rule**: damage numbers are useless if they're unreadable. Always: black outline (2-3px), font size >= 16, never overlap with other UI. If multiple numbers spawn simultaneously, stagger vertical positions by 20px.

**Critical hits need 3 things**: bigger font, different color, AND shake/extra effect. Just bigger font = "the number is bigger." All three = "THAT WAS A CRIT." Size alone tells you math. The combination tells you feeling.

---

## 4. Turn Transition Rhythm

The dead time between turns is where turn-based combat dies. The player's brain has nothing to process, so it disengages. Fill this gap with purpose.

### What Happens Between Turns

```gdscript
# turn_manager.gd — manages the flow between turns
class_name TurnManager
extends Node

signal turn_started(actor: Node2D)
signal turn_ended(actor: Node2D)
signal round_started(round_number: int)

@export var between_turn_delay := 0.3   # Breathing room between turns
@export var round_transition_delay := 0.6
@export var turn_indicator_duration := 0.4

var _combat_camera: CombatCamera
var _turn_order: Array[Node2D] = []
var _current_index := 0
var _round := 0
var _indicator_scene: PackedScene  # "Player Turn" / "Enemy Turn" banner


func start_combat(actors: Array[Node2D], camera: CombatCamera,
		indicator: PackedScene = null) -> void:
	_combat_camera = camera
	_indicator_scene = indicator
	_turn_order = actors
	_current_index = 0
	_round = 0
	_next_round()


func _next_round() -> void:
	_round += 1
	round_started.emit(_round)

	# Sort by speed or initiative
	_turn_order.sort_custom(func(a: Node2D, b: Node2D) -> bool:
		return a.get("speed") > b.get("speed") if a.get("speed") != null else false
	)

	_current_index = 0
	_process_turn()


func _process_turn() -> void:
	if _current_index >= _turn_order.size():
		# Round complete — brief pause, then new round
		await get_tree().create_timer(round_transition_delay).timeout
		_next_round()
		return

	var actor := _turn_order[_current_index]

	# Skip dead actors
	if not is_instance_valid(actor) or actor.get("is_dead"):
		_current_index += 1
		_process_turn()
		return

	# --- Turn transition choreography ---

	# 1. Camera eases to the active actor
	_combat_camera.focus_on(actor.global_position, 1.05, 0.25)

	# 2. Turn indicator (if provided)
	if _indicator_scene:
		_show_turn_indicator(actor)

	# 3. Subtle highlight on active actor
	_highlight_actor(actor)

	# 4. Brief beat before input/AI
	await get_tree().create_timer(between_turn_delay).timeout

	turn_started.emit(actor)


## Called by battle system when the current actor finishes their action
func end_current_turn() -> void:
	var actor := _turn_order[_current_index]
	_unhighlight_actor(actor)
	turn_ended.emit(actor)

	_current_index += 1

	# Brief pause between turns — the "breathing room"
	await get_tree().create_timer(between_turn_delay).timeout
	_process_turn()


func _highlight_actor(actor: Node2D) -> void:
	var tw := create_tween()
	tw.tween_property(actor, "modulate",
		Color(1.2, 1.2, 1.2), 0.15  # Slight overbright
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)


func _unhighlight_actor(actor: Node2D) -> void:
	var tw := create_tween()
	tw.tween_property(actor, "modulate",
		Color.WHITE, 0.2
	)


func _show_turn_indicator(actor: Node2D) -> void:
	var indicator := _indicator_scene.instantiate() as Control
	# Position above actor or at screen top — depends on game style
	get_tree().current_scene.add_child(indicator)

	if indicator.has_method("show_for"):
		indicator.show_for(actor, turn_indicator_duration)
	else:
		# Default: fade in, hold, fade out
		indicator.modulate.a = 0.0
		var tw := create_tween()
		tw.tween_property(indicator, "modulate:a", 1.0, 0.1)
		tw.tween_interval(turn_indicator_duration)
		tw.tween_property(indicator, "modulate:a", 0.0, 0.15)
		tw.tween_callback(indicator.queue_free)
```

### Transition Timing Values

| Between... | Delay | Camera action | Indicator | Feel |
|-----------|-------|---------------|-----------|------|
| Player turns | 0.2s | Subtle pan | None (same team) | Quick, maintain flow |
| Player → enemy | 0.4s | Pan across, zoom 1.05x | "Enemy Turn" banner | Build tension |
| Enemy → player | 0.3s | Pan back, zoom neutral | "Your Turn" banner | Relief, ready |
| Same actor combo | 0.1s | No change | None | Rapid chain |
| Round end → new round | 0.6s | Zoom out briefly | Round counter | Reset, breathe |

**The Persona 5 trick for turn transitions**: use a diagonal wipe/slash effect between turns instead of a simple fade or cut. The wipe adds visual motion even in the transition. It costs 0.2s extra but makes the transition feel like part of the action, not a pause.

---

## 5. Status Effect Communication

Status effects must be visible at a glance. The player needs to know: who is affected, by what, and how bad.

### Visual Language

| Status | Icon hint | Particle | Color tint | Character effect |
|--------|-----------|----------|------------|------------------|
| Burn | Flame | Rising embers, 3-5/s | Orange-red tint 10% | Subtle red pulse (0.5Hz) |
| Freeze | Snowflake | Slow ice crystals, 2/s | Cyan tint 15% | Movement speed halved visually |
| Shock | Lightning bolt | Quick sparks, 4-6/s | Yellow flashes | Random micro-jitters |
| Poison | Droplet | Green bubbles rising, 2-3/s | Green tint 10% | Slight desaturation |
| Bleed | Blood drop | Red drips falling, 2/s | Dark red tint 8% | Periodic flinch animation |
| Stun | Stars/spirals | Orbiting stars above head | Desaturate 30% | Swaying idle animation |
| Buff (ATK) | Sword up | Ascending orange sparks | Red-orange glow 5% | Slight scale 1.05x |
| Debuff (DEF) | Shield down | Descending blue particles | Blue-purple tint 8% | Slight scale 0.95x |

### Status Effect Shader (Per-Character Tint)

```gdshader
shader_type canvas_item;

uniform vec4 status_tint : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform float tint_strength : hint_range(0.0, 0.5) = 0.0;
uniform float pulse_speed : hint_range(0.0, 5.0) = 0.0;  // 0 = no pulse
uniform float pulse_amount : hint_range(0.0, 0.3) = 0.1;
uniform float desaturation : hint_range(0.0, 1.0) = 0.0;

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

	// Desaturate
	float luma = dot(tex.rgb, vec3(0.299, 0.587, 0.114));
	tex.rgb = mix(tex.rgb, vec3(luma), desaturation);

	// Tint with optional pulse
	float pulse = 1.0;
	if (pulse_speed > 0.0) {
		pulse = 1.0 - pulse_amount + pulse_amount * sin(TIME * pulse_speed * TAU);
	}

	tex.rgb = mix(tex.rgb, status_tint.rgb, tint_strength * pulse);
	COLOR = tex;
}
```

**Status shader values**:

| Status | tint_color | tint_strength | pulse_speed | pulse_amount | desaturation |
|--------|-----------|---------------|-------------|-------------|--------------|
| Burn | `(1.0, 0.3, 0.0)` | 0.15 | 0.5 | 0.1 | 0.0 |
| Freeze | `(0.0, 0.7, 1.0)` | 0.2 | 0.0 | 0.0 | 0.1 |
| Shock | `(1.0, 0.9, 0.0)` | 0.12 | 3.0 | 0.15 | 0.0 |
| Poison | `(0.2, 0.8, 0.0)` | 0.12 | 0.3 | 0.08 | 0.1 |
| Stun | `(0.8, 0.8, 0.8)` | 0.1 | 0.0 | 0.0 | 0.3 |
| Buff ATK | `(1.0, 0.5, 0.0)` | 0.08 | 0.8 | 0.05 | 0.0 |
| Debuff DEF | `(0.3, 0.2, 0.8)` | 0.1 | 0.5 | 0.08 | 0.05 |

**Stacking visualization**: when a character has 2+ statuses, the tints blend. This is fine for 2. For 3+, prioritize: show the most recent status tint and display ALL status icons above the character. Icons > tints for information. Tints > icons for atmosphere.

---

## 6. Action Selection Feel

The menu where the player picks their action is not a neutral interface. It's part of the combat rhythm. Persona 5 proved this: mapping Attack/Persona/Item/Guard to face buttons made selection feel like *performing* an action, not *choosing from a list*.

### Menu Cursor Feel

```gdscript
# combat_menu.gd — vertical action list with cursor juice
extends VBoxContainer

@export var cursor_slide_speed := 0.08    # Cursor movement duration
@export var selected_scale := 1.1
@export var unselected_alpha := 0.6
@export var cursor_bounce_amount := 3.0   # Oscillating cursor bounce (pixels)
@export var sfx_move: AudioStream
@export var sfx_confirm: AudioStream
@export var sfx_cancel: AudioStream

var _options: Array[Control] = []
var _current_index := 0
var _cursor: Control  # Visual cursor element (arrow, highlight, etc.)
var _audio: AudioStreamPlayer


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

	# Gather menu options
	for child in get_children():
		if child is Control and child != _cursor:
			_options.append(child)
			child.modulate.a = unselected_alpha

	if _options.size() > 0:
		_select_option(0, false)


func _input(event: InputEvent) -> void:
	if not visible:
		return

	if event.is_action_pressed("ui_down"):
		_move_cursor(1)
	elif event.is_action_pressed("ui_up"):
		_move_cursor(-1)
	elif event.is_action_pressed("ui_accept"):
		_confirm()
	elif event.is_action_pressed("ui_cancel"):
		_cancel()


func _move_cursor(direction: int) -> void:
	var new_index := wrapi(_current_index + direction, 0, _options.size())
	if new_index == _current_index:
		return

	_play_sfx(sfx_move)
	_select_option(new_index, true)


func _select_option(index: int, animate: bool) -> void:
	# Deselect previous
	if _current_index < _options.size():
		var prev := _options[_current_index]
		var tw := create_tween().set_parallel(true)
		tw.tween_property(prev, "scale", Vector2.ONE, 0.1)
		tw.tween_property(prev, "modulate:a", unselected_alpha, 0.1)

	_current_index = index
	var current := _options[_current_index]

	if animate:
		# Scale bump with BACK ease — the "weighty select" feel
		var tw := create_tween().set_parallel(true)
		tw.tween_property(current, "scale",
			Vector2.ONE * selected_scale, cursor_slide_speed
		).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
		tw.tween_property(current, "modulate:a", 1.0, cursor_slide_speed)
	else:
		current.scale = Vector2.ONE * selected_scale
		current.modulate.a = 1.0


func _confirm() -> void:
	_play_sfx(sfx_confirm)

	var selected := _options[_current_index]

	# Confirm flash: brief white, then action
	selected.modulate = Color(2.0, 2.0, 2.0)
	var tw := create_tween()
	tw.tween_property(selected, "modulate", Color.WHITE, 0.1)
	tw.tween_callback(func() -> void:
		# Emit selected action here
		pass
	)


func _cancel() -> void:
	_play_sfx(sfx_cancel)
	# Return to previous menu or cancel action selection


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

**Menu feel comparison**:

| Game | Cursor type | Move feel | Confirm feel | Speed |
|------|-----------|-----------|-------------|-------|
| Persona 5 | Face buttons (no cursor) | Instant, input = action | Heavy bass thud | Instant |
| Paper Mario | Bouncing arrow | Slide 0.1s + bounce | Pop SFX + scale burst | Fast |
| Darkest Dungeon | Highlight bar | Instant snap | Click + brief hold | Medium |
| Octopath Traveler | Gentle glow | Smooth slide 0.15s | Chime + fade | Deliberate |
| Dragon Quest XI | Classic arrow | Tick-tick movement | Confirm jingle | Traditional |

---

## 7. The Persona 5 Principle

Style IS substance. Persona 5 doesn't have faster turn-based combat than Dragon Quest. It has the same speed with better direction. Here's what they actually do:

### 1. Animation Overlap
Actions in P5 don't wait for the previous animation to fully finish. The camera starts moving before the menu fully closes. The damage number appears before the hit animation peaks. This 10-20% overlap creates perceived speed without cutting content.

### 2. Transition Wipes
Instead of a clean cut between attacker and target camera, P5 uses a directional slash wipe. The wipe adds 0.1s but makes the transition feel like *part of the action*. Dead time between turns gets a red/black diagonal slash that keeps visual energy alive.

### 3. Pose Holds
After each action, the character holds a dynamic pose for 0.3-0.5s. This isn't dead time — it's a reward pose. The player sees the result of their choice embodied in their character's attitude.

### 4. UI as Choreography
The battle menu isn't separate from the action. It appears in-world, attached to the character, with bold graphic design that matches the action's energy. The menu IS part of the combat visual language.

### 5. One-Button Immediacy
The moment you press a face button, the action starts. No "Are you sure?" No traversing submenus for basic attacks. The latency between input and response is < 0.1s. This alone makes combat feel "action-game fast."

### Implementation: Transition Wipe

```gdscript
# combat_wipe.gd — fullscreen diagonal wipe between combat phases
extends ColorRect

@export var wipe_color := Color(0.1, 0.0, 0.0)     # Dark red
@export var wipe_duration := 0.15                     # Fast slash
@export var wipe_angle := 30.0                        # Degrees from vertical
@export var wipe_hold := 0.05                         # Brief hold at peak

var _material: ShaderMaterial


func _ready() -> void:
	# Fullscreen overlay
	anchors_preset = Control.PRESET_FULL_RECT
	mouse_filter = Control.MOUSE_FILTER_IGNORE
	visible = false

	var shader := Shader.new()
	shader.code = _WIPE_SHADER
	_material = ShaderMaterial.new()
	_material.shader = shader
	_material.set_shader_parameter("wipe_color", wipe_color)
	_material.set_shader_parameter("progress", 0.0)
	_material.set_shader_parameter("angle", deg_to_rad(wipe_angle))
	material = _material


func play_wipe() -> void:
	visible = true

	# Wipe in
	var tw := create_tween()
	tw.tween_method(func(v: float) -> void:
		_material.set_shader_parameter("progress", v),
		0.0, 1.0, wipe_duration
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)

	# Hold
	tw.tween_interval(wipe_hold)

	# Wipe out
	tw.tween_method(func(v: float) -> void:
		_material.set_shader_parameter("progress", v),
		1.0, 0.0, wipe_duration
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)

	tw.tween_callback(func() -> void:
		visible = false
	)


const _WIPE_SHADER := "
shader_type canvas_item;

uniform vec4 wipe_color : source_color = vec4(0.1, 0.0, 0.0, 1.0);
uniform float progress : hint_range(0.0, 1.0) = 0.0;
uniform float angle : hint_range(-1.57, 1.57) = 0.52;
uniform float softness : hint_range(0.0, 0.2) = 0.02;

void fragment() {
	// Rotated UV for angled wipe
	vec2 uv = UV - 0.5;
	float rotated = uv.x * cos(angle) - uv.y * sin(angle) + 0.5;

	// Wipe mask
	float mask = smoothstep(progress - softness, progress + softness, rotated);
	float alpha = 1.0 - mask;

	COLOR = vec4(wipe_color.rgb, alpha * progress);
}
"
```

---

## 8. Battle Start / End

### Encounter Transition

The battle start transition bridges exploration and combat. It sets expectations: "combat is about to happen, pay attention."

**Classic JRPG pattern** (3 phases):
1. **Alert** (0.0s): screen flash white (0.1s), battle encounter SFX
2. **Transition** (0.1-0.5s): wipe, swirl, or shatter effect covering the screen
3. **Reveal** (0.5-1.0s): battle scene fades in, camera establishes the battlefield, enemies appear with brief intro animation

**Timing**:

| Style | Alert | Transition | Reveal | Total |
|-------|-------|------------|--------|-------|
| Random encounter (FF) | 0.1s flash | 0.4s spiral | 0.5s pan | 1.0s |
| Visible encounter (Persona 5) | 0.05s slash | 0.2s wipe | 0.3s pose | 0.55s |
| Ambush (surprise) | None | 0.15s smash cut | 0.2s enemy close-up | 0.35s |

### Victory Sequence

The emotional arc of victory: relief → celebration → reward. Each phase has its own timing.

```gdscript
# victory_sequence.gd — post-battle reward display
extends CanvasLayer

@export var victory_fanfare: AudioStream
@export var xp_tick_sfx: AudioStream
@export var item_reveal_sfx: AudioStream

var _audio: AudioStreamPlayer


func play_victory(xp_earned: int, items: Array[Dictionary],
		party: Array[Node2D]) -> void:
	_audio = AudioStreamPlayer.new()
	add_child(_audio)

	# Phase 1: Victory pose (0.5s)
	for member in party:
		if member.has_method("play_victory_pose"):
			member.play_victory_pose()

	_play_sfx(victory_fanfare)
	await get_tree().create_timer(0.5).timeout

	# Phase 2: XP reveal (count-up, 0.02s per tick)
	var xp_label := _create_xp_display()
	var tick_count := mini(xp_earned, 50)  # Cap ticks for large XP values
	var xp_per_tick := xp_earned / tick_count

	for i in tick_count:
		var displayed := (i + 1) * xp_per_tick
		xp_label.text = "EXP +%d" % displayed
		_play_sfx(xp_tick_sfx)
		# Pitch rises with each tick
		_audio.pitch_scale = 1.0 + (float(i) / float(tick_count)) * 0.5
		await get_tree().create_timer(0.02).timeout

	# Final XP number bump
	xp_label.text = "EXP +%d" % xp_earned
	_bump_label(xp_label)
	await get_tree().create_timer(0.3).timeout

	# Phase 3: Item reveals (staggered, rarest last)
	items.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
		return a.get("rarity", 0) < b.get("rarity", 0)
	)

	for i in items.size():
		await get_tree().create_timer(0.15).timeout
		_reveal_item(items[i])
		_play_sfx(item_reveal_sfx)

	# Phase 4: Wait for input to dismiss
	await _wait_for_confirm()
	_cleanup()


func _bump_label(label: Label) -> void:
	label.pivot_offset = label.size / 2.0
	var tw := create_tween()
	tw.tween_property(label, "scale", Vector2.ONE * 1.3, 0.08)\
		.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	tw.tween_property(label, "scale", Vector2.ONE, 0.2)\
		.set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)


func _reveal_item(item: Dictionary) -> void:
	# Create item display with icon + name + rarity color
	# Slide in from right with bounce
	pass  # Implementation depends on UI layout


func _create_xp_display() -> Label:
	var label := Label.new()
	label.text = "EXP +0"
	# Position and style depend on layout
	add_child(label)
	return label


func _wait_for_confirm() -> void:
	while true:
		await get_tree().process_frame
		if Input.is_action_just_pressed("ui_accept"):
			break


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


func _cleanup() -> void:
	queue_free()
```

---

## 9. Combo / Chain Systems

Successive hits should escalate. Each hit in a chain is louder, faster, and more impactful than the last. This is the Chrono Trigger dual tech principle: combined actions feel greater than the sum of their parts.

### Escalation Values

| Hit # | Pitch mult | Shake mult | Number scale mult | Camera zoom add | Freeze frame |
|-------|-----------|------------|-------------------|----------------|-------------|
| 1 | 1.0 | 1.0 | 1.0 | 0.0 | None |
| 2 | 1.05 | 1.2 | 1.05 | +0.02 | None |
| 3 | 1.10 | 1.4 | 1.1 | +0.04 | 0.02s |
| 4 | 1.18 | 1.7 | 1.15 | +0.06 | 0.03s |
| 5+ | 1.25 | 2.0 | 1.2 | +0.08 | 0.04s |
| Finisher | 1.0 (reset) | 3.0 | 1.5 | +0.15 | 0.08s |

**The finisher drops pitch back to 1.0** — this is counterintuitive but critical. The ascending pitch creates tension. The finisher releases it with a low, heavy impact. Rising pitch = building pressure. Drop = release. Same principle as a musical cadence.

### Chain Hit Controller

```gdscript
# chain_tracker.gd — tracks combo hits and provides escalation values
class_name ChainTracker
extends RefCounted

var hit_count := 0
var _max_escalation := 5

## Multipliers per hit
var pitch_curve := [1.0, 1.05, 1.10, 1.18, 1.25, 1.25]
var shake_curve := [1.0, 1.2, 1.4, 1.7, 2.0, 2.0]
var scale_curve := [1.0, 1.05, 1.1, 1.15, 1.2, 1.2]
var zoom_curve := [0.0, 0.02, 0.04, 0.06, 0.08, 0.08]
var freeze_curve := [0.0, 0.0, 0.02, 0.03, 0.04, 0.04]


func register_hit() -> Dictionary:
	hit_count += 1
	var idx := mini(hit_count - 1, _max_escalation)

	return {
		"hit_number": hit_count,
		"pitch_mult": pitch_curve[idx],
		"shake_mult": shake_curve[idx],
		"scale_mult": scale_curve[idx],
		"zoom_add": zoom_curve[idx],
		"freeze_time": freeze_curve[idx],
	}


func register_finisher() -> Dictionary:
	var result := {
		"hit_number": hit_count + 1,
		"pitch_mult": 1.0,        # Drop back — release tension
		"shake_mult": 3.0,
		"scale_mult": 1.5,
		"zoom_add": 0.15,
		"freeze_time": 0.08,
		"is_finisher": true,
	}
	reset()
	return result


func reset() -> void:
	hit_count = 0
```

**The time compression trick**: reduce the delay between hits as the combo progresses. Hit 1→2 gap: 0.3s. Hit 2→3 gap: 0.25s. Hit 3→4 gap: 0.2s. Hit 4→5 gap: 0.15s. The combo literally accelerates. Combined with pitch escalation, the player feels momentum building physically.

---

## 10. Combining Techniques (Full Stack)

### Stack: "Basic Attack Beat"

Total: ~0.7s. Bread and butter, must feel clean.

1. **Anticipation** (0.2s): camera zoom 1.1x on attacker, attacker steps forward 10px
2. **Action** (0.1s): camera fast-pan to target, attack animation plays
3. **Impact** (0.0s): camera shake 4px/0.1s, hit SFX, white flash on target (0.05s)
4. **Damage number** (0.0s): pops up 40px above target, white, scale 1.8→1.0, drifts up 40px over 0.8s
5. **Resolution** (0.3s): attacker returns to position, camera eases neutral, target idle animation resumes

### Stack: "Critical Ultimate Attack"

Total: ~2.0s. Rare, cinematic, player should feel powerful.

1. **Build** (0.5s): close-up zoom 1.4x on attacker, dramatic pose, screen darkens slightly (CanvasModulate 0.85)
2. **Wipe** (0.15s): diagonal slash wipe (dark red), transition to target view
3. **Impact** (0.0s): freeze frame 0.06s, heavy shake 12px/0.25s, screen flash white alpha 0.2
4. **Damage number** (0.0s): critical style — gold, scale 2.5→1.0, shake, "!" suffix, font 32
5. **Effect** (0.3s): impact particles burst from target, secondary screen shake 4px
6. **Pose** (0.4s): attacker holds victory pose, camera framing the result
7. **Resolve** (0.6s): slow ease back to neutral, CanvasModulate returns to 1.0

### Stack: "Full Battle Flow" (3 Rounds)

How an entire encounter feels:

1. **Encounter** (0.5s): screen flash + slash wipe, battle music starts
2. **Round 1 start** (0.3s): camera establishes battlefield, "Battle Start" indicator
3. **Player turn** (0.2s transition): camera focuses player, menu appears
4. **Player acts** (0.7s): basic attack beat
5. **Enemy turn** (0.4s transition): wipe, camera pans to enemy, "Enemy Turn"
6. **Enemy acts** (0.8s): enemy attack beat with player as target
7. **Round 2** (0.6s transition): brief zoom-out, round indicator
8. **Player turn** (0.2s): immediate menu (no wipe, same team)
9. **Player uses skill** (0.95s): spell beat — longer anticipation, effect travels
10. **Enemy killed** (0.5s): death animation, particles, enemy fades
11. **Victory** (2.0s): fanfare, victory poses, XP count-up, item reveals

Total: ~7-8s for 3 rounds. Each second is filled with purpose. Zero dead time.

---

## Gotchas

1. **Engine.time_scale for freeze frames** — setting time_scale < 0.1 blocks `create_timer()`. Use real-time calculation: `create_timer(desired_real_seconds * (1.0 / Engine.time_scale))`. Also see CLAUDE.md: time_scale < 0.15 blocks GolemCapture

2. **Camera shake stacking** — two actions happening simultaneously (multi-target attacks) can stack shakes. Cap total shake intensity in the camera controller. If `_shake_intensity` is already high, don't add more — take the max instead

3. **Damage numbers overlapping** — multiple hits on the same target spawn numbers at the same position. Stagger vertical offset: each new number spawns 20px higher than the previous. Or use alternating left/right drift to spread them

4. **Tween kill on scene change** — battle ending while tweens are running = errors on freed nodes. `_exit_tree()` must kill all active tweens. Use a tween array and kill all on cleanup

5. **Turn order visual feedback** — players NEED to see upcoming turn order. Without it, they can't plan. Display a turn order bar (portraits in sequence) and highlight the active actor. Update it when speed buffs/debuffs change the order

6. **Camera zoom + pixel art = subpixel jitter** — zooming the camera on pixel art creates shimmering. Snap the camera position to whole pixels after zooming: `global_position = global_position.round()`. Or use a viewport with integer scaling

7. **Transition wipe on low-end hardware** — fullscreen shader effects can stutter on integrated GPUs. Keep wipe shaders simple (no texture sampling, no complex math). The diagonal wipe shader above is minimal by design

8. **Audio pitch on chains + dodge/miss** — if the player misses mid-chain, reset the pitch escalation. A whiff at high pitch sounds comical, not dramatic. Reset `ChainTracker` on miss

9. **Status tint + damage flash conflict** — a character with burn tint (orange) taking damage (white flash) creates a weird color blend. Solution: damage flash overrides status tint for 0.05s, then blends back. The flash is always white regardless of status

10. **"Whose turn" confusion** — if the camera doesn't clearly communicate whose turn it is, players hesitate. The turn indicator banner is a safety net, but the camera focus is the primary signal. Always zoom toward the active actor before their turn begins — even 1.05x zoom is enough for the brain to register "that's the one acting"
