Chris Ferrario

Senior Gameplay & AI Programmer

Caribooze Simulator

Godot at-bat from the umpire’s view. Time the swing, put the ball in play, and take seven innings against the Phillies.

Return to Game List

Source

Godot 4.7 project — open the source folder, or read the main script below.

extends Node2D

const GOLD := Color(0.886, 0.714, 0.341)
const US_NAME := "CARIBOOZE"
const THEM_NAME := "PHILLIES"
const HANG := 0.28
const SWING_DELAY := 0.18
const BALL_TEX: Texture2D = preload("res://assets/ball.png")
const BAT_TEX: Texture2D = preload("res://assets/bat.png")
const SFX := {
	"crack": preload("res://assets/sfx/crack.wav"),
	"crack_soft": preload("res://assets/sfx/crack_soft.wav"),
	"pop": preload("res://assets/sfx/pop.wav"),
	"whiff": preload("res://assets/sfx/whiff.wav"),
	"cheer": preload("res://assets/sfx/cheer.wav"),
	"cheer_big": preload("res://assets/sfx/cheer_big.wav"),
	"cheer_small": preload("res://assets/sfx/cheer_small.wav"),
	"groan": preload("res://assets/sfx/groan.wav"),
	"call": preload("res://assets/sfx/call.wav"),
	"click": preload("res://assets/sfx/click.wav"),
	"crowd_loop": preload("res://assets/sfx/crowd_loop.wav"),
}
const PITCHES := [
	{"name": "Fastball", "dur": 0.66, "mph": 96, "hx": 0.0, "hy": 0.0},
	{"name": "Slider", "dur": 0.79, "mph": 88, "hx": 56.0, "hy": 14.0},
	{"name": "Curveball", "dur": 0.96, "mph": 80, "hx": -12.0, "hy": 80.0},
	{"name": "Changeup", "dur": 1.08, "mph": 76, "hx": 0.0, "hy": 24.0},
]
const ROSTER := [
	"Raj", "Gallo", "Campbell", "Randle E6", "Tucker", "Soso", "Infinity", "France", "Reed", "MVP",
	"Tuck's dad", "HRaldo", "PawPaw", "DRE", "Connor", "Farrar", "Luis", "Mark", "Pope", "Mack",
]
const AWAY_LINEUP := ["Schwarber", "Turner", "Harper", "Bohm", "Castellanos", "Realmuto", "Stott", "Marsh", "Sosa"]
const INNINGS := 7
const HOME_BASE := Vector2(640, 588)
const FIRST_BASE := Vector2(1020, 438)
const SECOND_BASE := Vector2(640, 288)
const THIRD_BASE := Vector2(260, 438)
const RUBBER := Vector2(640, 445.5)
const FIELDERS := [
	{"name": "3B", "x": 318.0, "y": 438.0, "sx": 312.0, "sy": 366.0},
	{"name": "SS", "x": 496.0, "y": 372.0, "sx": 470.0, "sy": 262.0},
	{"name": "2B", "x": 784.0, "y": 372.0, "sx": 810.0, "sy": 262.0},
	{"name": "1B", "x": 962.0, "y": 438.0, "sx": 986.0, "sy": 384.0},
	{"name": "LF", "x": 390.0, "y": 214.0, "sx": 250.0, "sy": 204.0},
	{"name": "CF", "x": 640.0, "y": 196.0, "sx": 640.0, "sy": 192.0},
	{"name": "RF", "x": 890.0, "y": 214.0, "sx": 1030.0, "sy": 204.0},
]

var font: Font
var zone := Rect2(594, 528, 92, 120)
var start := Vector2(652, 412)
var target := Vector2(640, 512)
var hand_pos := Vector2(624, 280)
var batter_i := 0
var pending_advance := false
var meet := false
var meet_pos := Vector2.ZERO
var hit_from := Vector2.ZERO
var hit_to := Vector2.ZERO
var hit_arc := 40.0
var hit_flight := 0.7
var hit_bases := 1
var hit_radius := 18.0
var on_base: Array = [-1, -1, -1]
var base_power: Array = [false, false, false]
var movers: Array = []
var trot_span := 1
var ball_t := 0.0
var whiff_wind := 0.0
var resolving_whiff := false
var pitch: Dictionary = {}
var pitch_dur := 0.7
var pitch_mph := 0
var play_idx := -1
var cover_idx := -1
var throw_base := -1
var throw_from := Vector2.ZERO
var throw_to := Vector2.ZERO
var throw_dur := 0.4
var last_kind := -1
var phase := "windup"
var phase_t := 0.0
var live := true
var swung := false
var swinging := false
var swing_left := 0.0
var swing_p := 0.0
var power_mode := false
var _styled := -1
var hits := 0
var outs := 0
var inning := 1
var inning_runs := 0
var our_line: Array = []
var their_line: Array = []
var our_hits := 0
var their_hits := 0
var half_outs := 0
var half_runs := 0
var half_hits := 0
var half_wait := 0.0
var opp_on: Array = [false, false, false]
var pending_half := false
var lineup: Array = []
var lefty: Array = []
var stats: Array = []
var opp_batter_i := 0
var opp_active := false
var opp_t := 0.0
var opp_lefty := false
var opp_result: Dictionary = {}
var opp_applied := false
var opp_hit_to := Vector2.ZERO
var opp_hit_arc := 30.0
var opp_target := Vector2(640, 588)
var opp_swing_p := 0.0
var opp_swings := true
var balls := 0
var strikes := 0
var message := ""
var ball := Vector2(340, 360)

var contact_btn: Button
var power_btn: Button
var again_btn: Button
var start_btn: Button
var pop_done := false
var react_done := false
var opp_react_done := false


func _ready() -> void:
	randomize()
	_build_ui()
	_shuffle_lineup()
	_start_crowd()
	live = false
	phase = "ready"
	print("READY Caribooze Simulator")


func _play(name: String, db := 0.0, pitch := 1.0) -> void:
	var stream: AudioStream = SFX.get(name)
	if stream == null:
		return
	var player := AudioStreamPlayer.new()
	player.stream = stream
	player.volume_db = db
	player.pitch_scale = pitch
	add_child(player)
	player.finished.connect(player.queue_free)
	player.play()


func _start_crowd() -> void:
	var base: AudioStreamWAV = SFX["crowd_loop"]
	var loop := base.duplicate() as AudioStreamWAV
	loop.loop_mode = AudioStreamWAV.LOOP_FORWARD
	loop.loop_begin = 0
	loop.loop_end = int(loop.data.size() / 2)
	var player := AudioStreamPlayer.new()
	player.stream = loop
	player.volume_db = -20.0
	add_child(player)
	player.play()


func _shuffle_lineup() -> void:
	var pool: Array = ROSTER.duplicate()
	pool.shuffle()
	lineup = pool.slice(0, 9)
	lefty = []
	stats = []
	for i in 9:
		lefty.append(randf() < 0.35)
		stats.append({"ab": 0, "h": 0, "bb": 0, "r": 0, "rbi": 0, "2b": 0, "3b": 0, "hr": 0, "k": 0})


func _stat(i: int, key: String, amount := 1) -> void:
	if i < 0 or i >= stats.size():
		return
	var row: Dictionary = stats[i]
	row[key] = int(row[key]) + amount


func _stat_line(i: int) -> String:
	if i < 0 or i >= stats.size():
		return ""
	var s: Dictionary = stats[i]
	var parts: Array[String] = []
	if int(s["ab"]) > 0:
		parts.append("%d/%d" % [int(s["h"]), int(s["ab"])])
	for key in ["bb", "r", "rbi", "2b", "3b", "hr", "k"]:
		var n := int(s[key])
		if n <= 0:
			continue
		parts.append(key if n == 1 else "%d %s" % [n, key])
	return ", ".join(parts)


func _new_game() -> void:
	_shuffle_lineup()
	batter_i = 0
	opp_batter_i = 0
	pending_advance = false
	inning = 1
	inning_runs = 0
	our_line = []
	their_line = []
	our_hits = 0
	their_hits = 0
	hits = 0
	outs = 0
	strikes = 0
	balls = 0
	on_base = [-1, -1, -1]
	base_power = [false, false, false]
	movers = []
	message = ""
	_begin_pitch()


func _process(dt: float) -> void:
	dt = minf(dt, 0.05)
	phase_t += dt
	if phase == "windup" and phase_t >= 0.42:
		phase = "flight"
		phase_t = 0.0
	if swinging and live and (phase == "windup" or phase == "flight"):
		swing_left = maxf(swing_left - dt, 0.0)
		swing_p = clampf(1.0 - swing_left / SWING_DELAY, 0.0, 1.0)
		if swing_left <= 0.0:
			swinging = false
			_resolve_swing()
	elif swing_p > 0.0 and swing_p < 1.4:
		swing_p = minf(swing_p + dt * 1.5, 1.4)
	if not pop_done:
		if (phase == "flight" and not swinging and phase_t >= pitch_dur) or (phase == "whiff" and whiff_wind <= 0.0 and ball_t >= pitch_dur):
			pop_done = true
			_play("pop", -2.0, randf_range(0.95, 1.05))
	if phase == "hit" and not react_done and phase_t >= hit_flight:
		react_done = true
		_crowd_reacts()
	if phase == "flight" and live and not swinging and phase_t >= pitch_dur + HANG:
		_take()
	elif phase == "hit" and phase_t >= _hit_len():
		if outs >= 3:
			_begin_opponent()
		else:
			_begin_pitch()
	elif phase == "trot" and phase_t >= 0.2 + float(trot_span) * 0.74:
		_begin_pitch()
	elif phase == "whiff":
		if whiff_wind > 0.0:
			whiff_wind = maxf(whiff_wind - dt, 0.0)
		else:
			ball_t += dt
		if whiff_wind <= 0.0 and ball_t >= pitch_dur + HANG:
			phase_t = 0.0
			if outs >= 3:
				_begin_opponent()
			else:
				phase = "show"
	elif phase == "show" and phase_t >= 1.15:
		if pending_half or outs >= 3:
			_begin_opponent()
		else:
			_begin_pitch()
	elif phase == "opponent":
		if opp_active:
			_tick_opp_pa(dt)
		else:
			half_wait = maxf(half_wait - dt, 0.0)
			if half_wait <= 0.0:
				if half_outs >= 3:
					_end_opponent_half()
				else:
					_start_opp_pa()
	_apply_mode()
	again_btn.visible = phase == "gameover" or phase == "final"
	start_btn.visible = phase == "ready"
	queue_redraw()


func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseButton:
		var mb := event as InputEventMouseButton
		if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
			_try_swing()
	elif event is InputEventKey:
		var key := event as InputEventKey
		if key.echo:
			return
		if key.keycode == KEY_SPACE and key.pressed:
			_try_swing()


func _build_ui() -> void:
	var layer := CanvasLayer.new()
	add_child(layer)
	contact_btn = Button.new()
	contact_btn.text = "CONTACT"
	contact_btn.position = Vector2(800, 16)
	contact_btn.size = Vector2(200, 52)
	contact_btn.focus_mode = Control.FOCUS_NONE
	contact_btn.pressed.connect(_set_contact)
	layer.add_child(contact_btn)
	power_btn = Button.new()
	power_btn.text = "POWER"
	power_btn.position = Vector2(1020, 16)
	power_btn.size = Vector2(220, 52)
	power_btn.focus_mode = Control.FOCUS_NONE
	power_btn.pressed.connect(_set_power)
	layer.add_child(power_btn)
	again_btn = Button.new()
	again_btn.text = "NEXT INNING"
	again_btn.position = Vector2(490, 640)
	again_btn.size = Vector2(300, 60)
	again_btn.visible = false
	again_btn.focus_mode = Control.FOCUS_NONE
	again_btn.pressed.connect(_advance_game)
	layer.add_child(again_btn)
	_paint(again_btn, true)
	for b in [contact_btn, power_btn, again_btn]:
		(b as Button).pressed.connect(_play.bind("click"))
	start_btn = Button.new()
	start_btn.text = "PLAY BALL"
	start_btn.position = Vector2(490, 640)
	start_btn.size = Vector2(300, 60)
	start_btn.focus_mode = Control.FOCUS_NONE
	start_btn.pressed.connect(_new_game)
	start_btn.pressed.connect(_play.bind("click"))
	layer.add_child(start_btn)
	_paint(start_btn, true)


func _advance_game() -> void:
	if phase == "final":
		_new_game()
	elif phase == "gameover":
		_new_inning()


func _set_contact() -> void:
	power_mode = false


func _set_power() -> void:
	power_mode = true


func _apply_mode() -> void:
	var mode := 1 if power_mode else 0
	if _styled == mode:
		return
	_styled = mode
	_paint(contact_btn, not power_mode)
	_paint(power_btn, power_mode)


func _paint(button: Button, selected: bool) -> void:
	var bg := Color(0.22, 0.17, 0.08) if selected else Color(0.12, 0.13, 0.15)
	var border := GOLD if selected else Color(0.42, 0.44, 0.46)
	button.add_theme_stylebox_override("normal", _box(bg, border))
	button.add_theme_stylebox_override("hover", _box(bg.lightened(0.08), border))
	button.add_theme_stylebox_override("pressed", _box(bg.lightened(0.12), GOLD))
	button.add_theme_stylebox_override("focus", _box(bg, border))
	button.add_theme_color_override("font_color", Color(0.96, 0.94, 0.9))
	button.add_theme_color_override("font_hover_color", GOLD)
	button.add_theme_font_size_override("font_size", 20)


func _box(bg: Color, border: Color) -> StyleBoxFlat:
	var style := StyleBoxFlat.new()
	style.bg_color = bg
	style.border_color = border
	style.set_border_width_all(2)
	style.set_corner_radius_all(10)
	style.content_margin_left = 12
	style.content_margin_right = 12
	style.content_margin_top = 8
	style.content_margin_bottom = 8
	return style


func _begin_pitch() -> void:
	var index := randi() % PITCHES.size()
	if index == last_kind:
		index = (index + 1 + randi() % (PITCHES.size() - 1)) % PITCHES.size()
	last_kind = index
	pitch = PITCHES[index]
	pitch_dur = float(pitch["dur"])
	pitch_mph = int(pitch["mph"]) + randi_range(-1, 1)
	if randf() < 0.64:
		target = Vector2(
			randf_range(zone.position.x + 6.0, zone.end.x - 6.0),
			randf_range(zone.position.y + 8.0, zone.end.y - 8.0)
		)
	else:
		var roll := randi() % 3
		if roll == 0:
			target = Vector2(randf_range(zone.position.x + 8.0, zone.end.x - 8.0), zone.position.y - randf_range(22.0, 52.0))
		elif roll == 1:
			target = Vector2(randf_range(zone.position.x + 8.0, zone.end.x - 8.0), zone.end.y + randf_range(18.0, 46.0))
		else:
			target = Vector2(zone.position.x - randf_range(28.0, 58.0), randf_range(zone.position.y + 12.0, zone.end.y - 12.0))
	if pending_advance:
		batter_i = (batter_i + 1) % lineup.size()
		pending_advance = false
	phase = "windup"
	phase_t = 0.0
	live = true
	pop_done = false
	react_done = false
	swung = false
	swinging = false
	swing_left = 0.0
	swing_p = 0.0
	meet = false
	message = ""
	play_idx = -1
	cover_idx = -1
	throw_base = -1


func _try_swing() -> void:
	if phase != "windup" and phase != "flight":
		return
	if swung or not live:
		return
	swung = true
	swinging = true
	swing_left = SWING_DELAY
	swing_p = 0.0


func _resolve_swing() -> void:
	if not live:
		return
	if phase == "windup":
		_whiff(str(pitch["name"]) + "  EARLY")
		return
	if phase != "flight":
		return
	var spot := _ball_pos(phase_t / pitch_dur)
	var err := absf(phase_t - pitch_dur)
	var in_zone := zone.has_point(target)
	var good := 0.18
	var edge := 0.25
	if power_mode:
		good = 0.12
		edge = 0.17
	var name := str(pitch["name"])
	if not in_zone:
		_whiff(name + "  CHASE")
	elif err <= edge and err > good:
		_finish(name + "  FOUL", false, false, true)
	elif err <= good:
		var kind := _batted(phase_t - pitch_dur, err, good)
		if kind == "foul":
			_finish(name + "  FOUL", false, false, true)
		elif _is_out_kind(kind):
			_finish(name + "  " + _play_label(kind), false, false, false, false, kind, spot, true)
		else:
			_finish(name + "  " + _play_label(kind), true, false, false, false, kind, spot)
	else:
		_whiff(name + "  MISS")


func _is_out_kind(kind: String) -> bool:
	return kind == "groundout" or kind == "flyout" or kind == "lineout" or kind == "popup"


func _play_label(kind: String) -> String:
	if kind == "home":
		return "HOME RUN"
	if kind == "triple":
		return "TRIPLE"
	if kind == "double":
		return "DOUBLE"
	if kind == "groundout":
		return "GROUNDOUT"
	if kind == "flyout":
		return "FLY OUT"
	if kind == "lineout":
		return "LINE OUT"
	if kind == "popup":
		return "POP OUT"
	return "SINGLE"


func _batted(signed: float, err: float, good: float) -> String:
	var is_left: bool = lefty[batter_i] == true
	var hand := -1.0 if is_left else 1.0
	var spray := clampf(signed / good, -1.0, 1.0) * hand
	var q := 1.0 - clampf(err / good, 0.0, 1.0)
	var carry := pow(q, 0.85)
	if absf(spray) > 0.94 and q < 0.28:
		return "foul"
	var ground_p := clampf(0.5 - carry * 0.32, 0.08, 0.58)
	if power_mode:
		ground_p = maxf(ground_p - 0.14, 0.05)
	if randf() < ground_p:
		return _ground_ball(spray, carry)
	return _air_ball(spray, carry)


func _ground_ball(spray: float, carry: float) -> String:
	spray = _aim_gap(spray, carry, [-0.7, 0.7])
	var depth := lerpf(0.12, 0.36, carry)
	var arc := lerpf(18.0, 8.0, carry)
	var spot := _fair_spot(spray, depth)
	var fielder := _nearest_fielder(spot)
	var dist := _charge_dist(spot, fielder)
	var reach := lerpf(96.0, 32.0, carry * carry)
	if dist < reach:
		_set_batted(spot, arc, depth, "groundout")
		return "groundout"
	if carry > 0.82 and dist > reach + 22.0 and randf() < 0.28:
		return _place_extra(spray, carry, 26.0, false)
	_set_batted(spot, arc, depth, "single")
	return "single"


func _air_ball(spray: float, carry: float) -> String:
	var liner := carry > 0.48 and randf() < lerpf(0.18, 0.62, carry)
	spray = _aim_gap(spray, carry, [-0.64, -0.18, 0.18, 0.64])
	var depth := lerpf(0.24, 0.55, carry)
	var arc := lerpf(76.0, 46.0, carry)
	if liner:
		depth = lerpf(0.32, 0.62, carry)
		arc = lerpf(32.0, 14.0, carry)
	if carry > 0.76:
		depth = lerpf(0.95, 0.99, carry)
		arc = 40.0 if liner else lerpf(86.0, 104.0, carry)
		if power_mode:
			depth = minf(depth + 0.02, 0.99)
		if not liner and carry > 0.91 and randf() < (0.48 if power_mode else 0.2):
			depth = 1.65
			arc = 128.0
	elif power_mode:
		depth = minf(depth + 0.06, 0.68)
	if depth >= 1.0:
		_set_batted(_fair_spot(spray, depth), arc, depth, "home")
		return "home"
	var spot := _fair_spot(spray, depth)
	var fielder := _nearest_fielder(spot)
	var dist := spot.distance_to(_home_spot(fielder))
	var reach := lerpf(92.0, 28.0, carry * carry)
	if liner:
		reach *= lerpf(0.78, 0.48, carry)
	if dist < reach:
		var kind := "flyout"
		if liner:
			kind = "lineout"
		elif carry < 0.38 and _infielder(fielder):
			kind = "popup"
		_set_batted(spot, arc, depth, kind)
		return kind
	if depth >= 0.9:
		return _place_extra(spray, carry, arc, absf(spray) > 0.5)
	_set_batted(spot, arc, depth, "single")
	return "single"


func _place_extra(spray: float, carry: float, arc: float, corner: bool) -> String:
	var kind := "double"
	var depth := lerpf(0.95, 0.99, carry)
	if corner and carry > 0.9 and randf() < 0.22:
		kind = "triple"
		depth = 1.0
		arc = maxf(arc, 100.0)
		var pull_side := 1.0 if spray >= 0.0 else -1.0
		spray = pull_side * maxf(absf(spray), 0.8)
	else:
		arc = maxf(arc, 36.0)
	_set_batted(_fair_spot(spray, depth), arc, depth, kind)
	return kind


func _aim_gap(spray: float, carry: float, lanes: Array) -> float:
	if carry < 0.32:
		return spray
	var best := float(lanes[0])
	var best_d := 99.0
	for lane in lanes:
		var lane_d := absf(spray - float(lane))
		if lane_d < best_d:
			best_d = lane_d
			best = float(lane)
	return lerpf(spray, best, clampf((carry - 0.28) * 1.35, 0.0, 0.84))


func _set_batted(spot: Vector2, arc: float, depth: float, kind: String) -> void:
	hit_to = spot
	hit_arc = arc
	hit_flight = lerpf(0.34, 0.58, clampf(depth, 0.0, 1.0))
	if kind == "groundout":
		hit_flight = lerpf(0.24, 0.34, clampf(depth, 0.0, 1.0))
	elif kind == "double":
		hit_flight = lerpf(0.8, 0.66, clampf((depth - 0.9) / 0.1, 0.0, 1.0))
	elif kind == "triple":
		hit_flight = 0.9
	elif kind == "home":
		hit_flight = 1.02
	if kind == "home":
		hit_bases = 4
	elif kind == "triple":
		hit_bases = 3
	elif kind == "double":
		hit_bases = 2
	elif kind == "single":
		hit_bases = 1
	else:
		hit_bases = 0


func _fair_spot(spray: float, depth: float) -> Vector2:
	var y := lerpf(505.0, 172.0, clampf(depth, 0.0, 1.0))
	if depth > 1.0:
		y = lerpf(150.0, 76.0, clampf(depth - 1.0, 0.0, 1.0))
	var dy := HOME_BASE.y - y
	var half := maxf(dy, 0.0) * (380.0 / 150.0)
	var x := 640.0 + clampf(spray, -1.05, 1.05) * half * 0.72
	return Vector2(clampf(x, 72.0, 1208.0), y)


func _charge_dist(spot: Vector2, fielder: int) -> float:
	return minf(spot.distance_to(_home_spot(fielder)), spot.distance_to(Vector2(640.0, 428.0)))


func _infielder(i: int) -> bool:
	var name := str(FIELDERS[i]["name"])
	return name == "1B" or name == "2B" or name == "SS" or name == "3B"


func _whiff(text: String) -> void:
	resolving_whiff = true
	_play("whiff", -2.0, randf_range(0.9, 1.1))
	_finish(text, false, true)


func _take() -> void:
	if zone.has_point(target):
		_play("call", -4.0, randf_range(0.92, 1.08))
		_finish(str(pitch["name"]) + "  TAKEN", false, true)
	else:
		_finish(str(pitch["name"]) + "  BALL", false, false, false, true)


func _crowd_reacts() -> void:
	if throw_base == -1:
		return
	var is_out := hit_bases == 0
	if is_out:
		_play("groan", -6.0)
		return
	var scores := false
	for m in movers:
		if int(m["dest"]) >= 4:
			scores = true
	_play("cheer" if scores else "cheer_small", -4.0 if scores else -6.0)


func _finish(text: String, is_hit: bool, is_strike: bool, is_foul := false, is_ball := false, kind := "", spot := Vector2.ZERO, is_out := false) -> void:
	if not live:
		return
	var clock := phase_t
	var was_flight := phase == "flight"
	live = false
	var extra := ""
	var pa_over := false
	var walking := false
	if is_foul:
		_play("crack_soft", -6.0, randf_range(0.85, 1.0))
	if is_hit:
		hits += 1
		strikes = 0
		balls = 0
		pa_over = true
		_stat(batter_i, "ab")
		_stat(batter_i, "h")
		if kind == "double":
			_stat(batter_i, "2b")
		elif kind == "triple":
			_stat(batter_i, "3b")
		elif kind == "home":
			_stat(batter_i, "hr")
		_launch(kind, spot)
	elif is_out:
		outs += 1
		strikes = 0
		balls = 0
		pa_over = true
		_stat(batter_i, "ab")
		_launch(kind, spot)
	elif is_ball:
		balls += 1
		if balls >= 4:
			extra = "  WALK"
			strikes = 0
			balls = 0
			pa_over = true
			walking = true
			_stat(batter_i, "bb")
			_play("cheer_small", -8.0)
			_send_runners(1, true)
	elif is_foul:
		if strikes < 2:
			strikes += 1
	elif is_strike:
		strikes += 1
		if strikes >= 3:
			extra = "  STRIKEOUT"
			strikes = 0
			balls = 0
			outs += 1
			pa_over = true
			_stat(batter_i, "ab")
			_stat(batter_i, "k")
			_play("groan", -8.0)
	if pa_over:
		pending_advance = true
	message = text + extra
	phase_t = 0.0
	if resolving_whiff:
		phase = "whiff"
		if was_flight:
			ball_t = clock
			whiff_wind = 0.0
		else:
			ball_t = 0.0
			whiff_wind = maxf(0.42 - clock, 0.0)
		if outs >= 3:
			message = "INNING OVER"
	elif is_hit or is_out:
		phase = "hit"
	elif outs >= 3:
		pending_half = true
		phase = "show"
		message = "INNING OVER"
	elif walking:
		phase = "trot"
	else:
		phase = "show"
	resolving_whiff = false


func _launch(kind: String, spot: Vector2) -> void:
	meet = true
	meet_pos = spot
	hit_from = spot
	hit_radius = lerpf(7.0, 24.0, clampf(phase_t / pitch_dur, 0.0, 1.0))
	if kind == "home" or kind == "triple" or kind == "double" or kind == "lineout":
		_play("crack", 0.0, randf_range(0.95, 1.05))
	elif kind == "single":
		_play("crack", -3.0, randf_range(0.95, 1.1))
	else:
		_play("crack_soft", -2.0, randf_range(0.9, 1.1))
	if kind == "home":
		_play("cheer_big", -2.0)
	if kind == "groundout":
		_force_groundout()
	elif _is_out_kind(kind):
		movers = []
		trot_span = 1
		_hold_runners()
	else:
		_send_runners(hit_bases, false)
	_plan_throw(kind)


func _force_groundout() -> void:
	var old: Array = [on_base[0], on_base[1], on_base[2]]
	var old_pow: Array = [base_power[0] == true, base_power[1] == true, base_power[2] == true]
	var nxt: Array = [-1, -1, -1]
	var nxt_pow: Array = [false, false, false]
	movers = []
	trot_span = 1
	if int(old[0]) != -1:
		if int(old[1]) != -1 and int(old[2]) != -1:
			_queue_runner(int(old[2]), 3, 4, old_pow[2])
		elif int(old[2]) != -1:
			_queue_runner(int(old[2]), 3, 3, old_pow[2])
			nxt[2] = old[2]
			nxt_pow[2] = old_pow[2]
		if int(old[1]) != -1:
			_queue_runner(int(old[1]), 2, 3, old_pow[1])
			nxt[2] = old[1]
			nxt_pow[2] = old_pow[1]
		_queue_runner(int(old[0]), 1, 2, old_pow[0])
		nxt[1] = old[0]
		nxt_pow[1] = old_pow[0]
	else:
		for slot in [1, 2]:
			if int(old[slot]) == -1:
				continue
			var bag: int = slot + 1
			_queue_runner(int(old[slot]), bag, bag, old_pow[slot])
			nxt[slot] = old[slot]
			nxt_pow[slot] = old_pow[slot]
	_queue_runner(batter_i, 0, 1, power_mode)
	on_base = nxt
	base_power = nxt_pow


func _hold_runners() -> void:
	for slot in [0, 1, 2]:
		if int(on_base[slot]) == -1:
			continue
		var bag: int = slot + 1
		_queue_runner(int(on_base[slot]), bag, bag, base_power[slot] == true)


func _hit_len() -> float:
	var trot := 0.16 + float(trot_span) * 0.74
	if throw_base == -2:
		return maxf(trot, hit_flight + 0.55)
	if throw_base < 0:
		return maxf(trot, hit_flight + 0.35)
	return maxf(trot, hit_flight + 0.12 + throw_dur + 0.28)


func _plan_throw(kind: String) -> void:
	play_idx = _nearest_fielder(hit_to)
	if kind == "home":
		throw_base = -1
	elif kind == "flyout" or kind == "popup" or kind == "lineout":
		throw_base = -2
	elif kind == "groundout":
		throw_base = 1
	else:
		throw_base = _correct_base(kind, play_idx)
	if throw_base < 0:
		cover_idx = -1
		throw_dur = 0.0
		if throw_base == -2:
			throw_from = hit_to
		return
	cover_idx = _cover_fielder(throw_base, play_idx)
	throw_from = _glove_at(_catch_spot(play_idx))
	throw_to = HOME_BASE + Vector2(0, -8) if throw_base == 0 else _glove_at(_bag_spot(throw_base))
	var natural := clampf(throw_from.distance_to(throw_to) / 720.0, 0.28, 0.9)
	var eta := _play_eta(throw_base)
	var release := hit_flight + 0.1
	if kind == "groundout":
		throw_dur = clampf(0.68 - (hit_flight + 0.08), 0.16, natural)
	elif eta <= 0.0:
		throw_dur = natural
	else:
		throw_dur = clampf(maxf(natural, eta + 0.12 - release), 0.28, 1.25)


func _correct_base(kind: String, fielder: int) -> int:
	if kind == "home":
		return -1
	var lead_start := -1
	var lead_dest := 1
	for m in movers:
		var start_b := int(m["start"])
		if start_b >= lead_start:
			lead_start = start_b
			lead_dest = int(m["dest"])
	if lead_dest >= 4:
		return 0
	var name := str(FIELDERS[fielder]["name"])
	var infield := name == "1B" or name == "2B" or name == "SS" or name == "3B"
	if infield and lead_dest == 1:
		return 1
	if not infield and lead_dest == 1:
		return 2
	return lead_dest


func _play_eta(base: int) -> float:
	var gate := 4 if base == 0 else base
	var eta := 0.0
	var lead := -1
	for m in movers:
		var start_b := int(m["start"])
		var dest_b := int(m["dest"])
		if dest_b < gate or start_b >= gate:
			continue
		if start_b < lead:
			continue
		lead = start_b
		eta = 0.12 + float(gate - start_b) * 0.74
	return eta


func _nearest_fielder(spot: Vector2) -> int:
	var best := 0
	var best_d := 100000.0
	for i in FIELDERS.size():
		var d := spot.distance_squared_to(_home_spot(i))
		if d < best_d:
			best_d = d
			best = i
	return best


func _cover_fielder(base: int, fielder: int) -> int:
	if base == 1:
		return _fielder_named("1B", fielder, "2B")
	if base == 3:
		return _fielder_named("3B", fielder, "SS")
	if base != 2:
		return -1
	var prefer := "SS" if hit_to.x >= 640.0 else "2B"
	var other := "2B" if prefer == "SS" else "SS"
	return _fielder_named(prefer, fielder, other)


func _fielder_named(want: String, busy: int, fallback: String) -> int:
	var found := -1
	var spare := -1
	for i in FIELDERS.size():
		var name := str(FIELDERS[i]["name"])
		if name == want:
			found = i
		if name == fallback:
			spare = i
	if found >= 0 and found != busy:
		return found
	return spare


func _home_spot(i: int) -> Vector2:
	return Vector2(float(FIELDERS[i]["x"]), float(FIELDERS[i]["y"]))


func _stand_spot(i: int) -> Vector2:
	return Vector2(float(FIELDERS[i]["sx"]), float(FIELDERS[i]["sy"]))


func _fielder_scale(y: float) -> float:
	return clampf(0.52 + (y - 180.0) / 620.0, 0.6, 0.9)


func _catch_spot(i: int) -> Vector2:
	var home := _home_spot(i)
	if throw_base == -2:
		var sc := _fielder_scale(hit_to.y + 20.0)
		return hit_to - Vector2(8, -26) * sc
	if throw_base < 0:
		return home.lerp(hit_to, 0.35)
	var dir := home - hit_to
	if dir.length() < 1.0:
		dir = Vector2(0, 1)
	return hit_to + dir.normalized() * 18.0


func _bag_spot(base: int) -> Vector2:
	var bag := _base_at(base)
	if base == 1:
		return bag + Vector2(-30, 24)
	if base == 3:
		return bag + Vector2(30, 24)
	return bag + Vector2(18, 26)


func _glove_at(feet: Vector2) -> Vector2:
	return feet + Vector2(8, -26) * _fielder_scale(feet.y)


func _smooth(u: float) -> float:
	return u * u * (3.0 - 2.0 * u)


func _fielder_spot(i: int) -> Vector2:
	var home := _stand_spot(i)
	if phase != "hit" or play_idx < 0:
		return home
	if i == play_idx:
		return home.lerp(_catch_spot(i), _smooth(clampf(phase_t / maxf(hit_flight, 0.05), 0.0, 1.0)))
	if i == cover_idx and throw_base > 0:
		var u := _smooth(clampf((phase_t - 0.06) / maxf(hit_flight * 0.65, 0.2), 0.0, 1.0))
		return home.lerp(_bag_spot(throw_base), u)
	return home


func _new_inning() -> void:
	inning += 1
	inning_runs = 0
	hits = 0
	outs = 0
	strikes = 0
	balls = 0
	on_base = [-1, -1, -1]
	base_power = [false, false, false]
	movers = []
	message = ""
	_begin_pitch()


func _begin_opponent() -> void:
	if our_line.size() < inning:
		our_line.append(inning_runs)
		our_hits += hits
	pending_half = false
	opp_on = [false, false, false]
	on_base = [-1, -1, -1]
	base_power = [false, false, false]
	movers = []
	meet = false
	swung = false
	swinging = false
	swing_left = 0.0
	swing_p = 0.0
	half_outs = 0
	half_runs = 0
	half_hits = 0
	half_wait = 0.5
	opp_active = false
	opp_swing_p = 0.0
	live = false
	phase = "opponent"
	phase_t = 0.0
	message = THEM_NAME + "    BATTING"


func _end_opponent_half() -> void:
	if their_line.size() < inning:
		their_line.append(half_runs)
		their_hits += half_hits
	phase_t = 0.0
	opp_active = false
	var us := _tally(our_line)
	var them := _tally(their_line)
	if inning >= INNINGS and us != them:
		phase = "final"
		var verdict := "WIN" if us > them else "LOSS"
		message = "FINAL    %s %d    %s %d    %s" % [US_NAME, us, THEM_NAME, them, verdict]
		again_btn.text = "NEW GAME"
		_play("cheer_big" if us > them else "groan", 0.0)
	else:
		phase = "gameover"
		message = "END %d    %s %d    %s %d" % [inning, US_NAME, us, THEM_NAME, them]
		again_btn.text = "NEXT INNING"


func _start_opp_pa() -> void:
	var roll := randf()
	var kind := "out"
	if roll >= 0.985:
		kind = "home"
	elif roll >= 0.96:
		kind = "triple"
	elif roll >= 0.90:
		kind = "double"
	elif roll >= 0.72:
		kind = "single"
	elif roll >= 0.62:
		kind = "walk"
	elif roll >= 0.40:
		kind = "strikeout"
	var spray := randf_range(-0.9, 0.9)
	var depth := 0.3
	opp_hit_arc = 30.0
	if kind == "out":
		var fielder := randi() % FIELDERS.size()
		opp_hit_to = _stand_spot(fielder)
		opp_hit_arc = 18.0 if _infielder(fielder) else 70.0
	elif kind == "single":
		depth = randf_range(0.28, 0.55)
		opp_hit_arc = randf_range(14.0, 40.0)
	elif kind == "double":
		depth = randf_range(0.9, 0.98)
		spray = signf(spray) * randf_range(0.45, 0.85)
		opp_hit_arc = 46.0
	elif kind == "triple":
		depth = 1.0
		spray = signf(spray) * randf_range(0.8, 1.0)
		opp_hit_arc = 40.0
	elif kind == "home":
		depth = 1.6
		spray = randf_range(-0.7, 0.7)
		opp_hit_arc = 90.0
	if kind != "out":
		opp_hit_to = _fair_spot(spray, depth)
	opp_swings = kind != "walk"
	opp_lefty = randf() < 0.4
	opp_target = Vector2(randf_range(zone.position.x + 10.0, zone.end.x - 10.0), randf_range(zone.position.y + 12.0, zone.end.y - 12.0))
	if kind == "walk":
		opp_target.x = zone.position.x - 40.0 if opp_lefty else zone.end.x + 40.0
	opp_result = {"kind": kind}
	opp_applied = false
	opp_t = 0.0
	opp_swing_p = 0.0
	opp_active = true
	message = "%s    %s" % [THEM_NAME, AWAY_LINEUP[opp_batter_i]]


func _tick_opp_pa(dt: float) -> void:
	opp_t += dt
	var contact := 0.5
	if opp_swings:
		opp_swing_p = clampf((opp_t - (contact - 0.16)) / 0.16, 0.0, 1.0)
		if opp_swing_p >= 1.0:
			opp_swing_p = minf(1.0 + (opp_t - contact) * 1.5, 1.4)
	if not opp_applied and opp_t >= contact:
		opp_applied = true
		opp_react_done = false
		var kind := str(opp_result.get("kind", "out"))
		if kind == "walk" or kind == "strikeout":
			_play("pop", -4.0)
			if kind == "strikeout":
				_play("call", -6.0)
		else:
			_play("crack" if kind != "out" else "crack_soft", -5.0, randf_range(0.95, 1.1))
		_apply_opp_result()
	if opp_applied and not opp_react_done and opp_t >= contact + 0.3:
		opp_react_done = true
		var kind := str(opp_result.get("kind", "out"))
		if kind == "out" or kind == "strikeout":
			_play("cheer_small", -8.0)
		elif kind == "home":
			_play("groan", -4.0)
		elif kind != "walk":
			_play("groan", -9.0)
	if opp_t >= contact + 0.55:
		opp_active = false
		opp_batter_i = (opp_batter_i + 1) % AWAY_LINEUP.size()
		half_wait = 0.12


func _apply_opp_result() -> void:
	var before := half_runs
	var kind := str(opp_result["kind"])
	var who: String = AWAY_LINEUP[opp_batter_i]
	var note := ""
	if kind == "out" or kind == "strikeout":
		half_outs += 1
		note = ("STRIKEOUT" if kind == "strikeout" else "OUT") + "    %d OUT" % half_outs
	elif kind == "walk":
		_opp_advance(1, true)
		note = "WALK"
	else:
		half_hits += 1
		if kind == "single":
			_opp_advance(1, false)
			note = "SINGLE"
		elif kind == "double":
			_opp_advance(2, false)
			note = "DOUBLE"
		elif kind == "triple":
			_opp_advance(3, false)
			note = "TRIPLE"
		else:
			_opp_advance(4, false)
			note = "HOME RUN"
	var scored := half_runs - before
	message = "%s    %s    %s" % [THEM_NAME, who, note]
	if scored == 1:
		message += "    RUN SCORES"
	elif scored > 1:
		message += "    %d RUNS" % scored


func _opp_advance(bases_taken: int, force_only: bool) -> void:
	var old: Array = [opp_on[0], opp_on[1], opp_on[2]]
	var nxt: Array = [false, false, false]
	if force_only:
		if old[0]:
			if old[1]:
				if old[2]:
					half_runs += 1
				nxt[2] = old[1]
			else:
				nxt[2] = old[2]
			nxt[1] = old[0]
		else:
			nxt[1] = old[1]
			nxt[2] = old[2]
		nxt[0] = true
	else:
		for slot in [2, 1, 0]:
			if not old[slot]:
				continue
			var dest: int = slot + 1 + bases_taken
			if dest >= 4:
				half_runs += 1
			else:
				nxt[dest - 1] = true
		if bases_taken >= 4:
			half_runs += 1
		else:
			nxt[bases_taken - 1] = true
	opp_on = nxt


func _tally(line: Array) -> int:
	var sum := 0
	for v in line:
		sum += int(v)
	return sum


func _send_runners(bases_taken: int, force_only: bool) -> void:
	var old: Array = [on_base[0], on_base[1], on_base[2]]
	var old_pow: Array = [base_power[0] == true, base_power[1] == true, base_power[2] == true]
	var nxt: Array = [-1, -1, -1]
	var nxt_pow: Array = [false, false, false]
	movers = []
	trot_span = 1
	if force_only:
		if int(old[0]) != -1:
			if int(old[1]) != -1:
				if int(old[2]) != -1:
					_queue_runner(int(old[2]), 3, 4, old_pow[2])
				_queue_runner(int(old[1]), 2, 3, old_pow[1])
				nxt[2] = old[1]
				nxt_pow[2] = old_pow[1]
			else:
				nxt[2] = old[2]
				nxt_pow[2] = old_pow[2]
			_queue_runner(int(old[0]), 1, 2, old_pow[0])
			nxt[1] = old[0]
			nxt_pow[1] = old_pow[0]
		else:
			nxt[1] = old[1]
			nxt_pow[1] = old_pow[1]
			nxt[2] = old[2]
			nxt_pow[2] = old_pow[2]
		_queue_runner(batter_i, 0, 1, power_mode)
		nxt[0] = batter_i
		nxt_pow[0] = power_mode
	else:
		for slot in [2, 1, 0]:
			if int(old[slot]) == -1:
				continue
			var start_b: int = slot + 1
			var dest_b := mini(start_b + bases_taken, 4)
			_queue_runner(int(old[slot]), start_b, dest_b, old_pow[slot])
			if dest_b <= 3:
				nxt[dest_b - 1] = old[slot]
				nxt_pow[dest_b - 1] = old_pow[slot]
		var batter_dest := mini(bases_taken, 4)
		_queue_runner(batter_i, 0, batter_dest, power_mode)
		if batter_dest <= 3:
			nxt[batter_dest - 1] = batter_i
			nxt_pow[batter_dest - 1] = power_mode
	on_base = nxt
	base_power = nxt_pow


func _queue_runner(who: int, start_b: int, dest_b: int, powered: bool) -> void:
	movers.append({"id": who, "start": start_b, "dest": dest_b, "power": powered})
	trot_span = maxi(trot_span, dest_b - start_b)
	if dest_b >= 4 and dest_b > start_b:
		inning_runs += 1
		_stat(who, "r")
		_stat(batter_i, "rbi")


func _ball_pos(t: float) -> Vector2:
	var u := clampf(t, 0.0, 1.0)
	var point := start.lerp(target, u)
	var hump := sin(u * PI)
	point.x += float(pitch["hx"]) * hump
	point.y += float(pitch["hy"]) * hump
	return point


func _draw() -> void:
	if font == null:
		font = ThemeDB.fallback_font
	var home := HOME_BASE
	var first := FIRST_BASE
	var second := SECOND_BASE
	var third := THIRD_BASE
	_draw_sky()
	_draw_stands()
	_draw_outfield()
	var dirt := Color(0.64, 0.46, 0.28)
	var skin := _infield_skin()
	draw_colored_polygon(skin, dirt)
	draw_polyline(skin, dirt.darkened(0.18), 2.0, true)
	draw_colored_polygon(PackedVector2Array([
		_skirt(third, home, first, -26.0),
		_skirt(home, first, second, -20.0),
		_skirt(first, second, third, -16.0),
		_skirt(second, third, home, -20.0),
	]), Color(0.16, 0.46, 0.20))
	_fill_ellipse(home + Vector2(0, 30), 232, 70, dirt)
	_fill_ellipse(RUBBER + Vector2(0, 3), 56, 20, dirt.darkened(0.08))
	_fill_ellipse(RUBBER, 30, 10, Color(0.58, 0.41, 0.24))
	draw_rect(Rect2(RUBBER.x - 14, RUBBER.y - 3, 28, 5), Color(0.96, 0.96, 0.97))
	var chalk := Color(0.96, 0.97, 0.95)
	var apex := home + Vector2(0, 8)
	var box_top := 556.0
	var exit_l := apex + (third - apex) * ((apex.y - box_top) / (apex.y - third.y))
	var exit_r := apex + (first - apex) * ((apex.y - box_top) / (apex.y - first.y))
	draw_line(_ray(home, third, 0.0), exit_l, chalk, 2.5, true)
	draw_line(_ray(home, first, 1280.0), exit_r, chalk, 2.5, true)
	_chalk_box(_batter_box(-1.0))
	_chalk_box(_batter_box(1.0))
	_bag(second, first, third, 11.0)
	_bag(first, home, second, 15.0)
	_bag(third, second, home, 15.0)
	_home_plate(home)
	_draw_fielders(true)
	_draw_pitcher()
	_draw_fielders(false)
	if phase != "trot" and (phase != "hit" or swing_p < 1.28):
		_draw_batter()
	_draw_baserunners()
	_draw_catcher()
	var hot := phase == "flight" and absf(phase_t - pitch_dur) < 0.1
	_draw_zone(GOLD if hot else Color(0.96, 0.97, 0.95))
	_draw_ball()
	_draw_hud()


func _ray(a: Vector2, b: Vector2, edge_x: float) -> Vector2:
	var dir := b - a
	if absf(dir.x) < 0.001:
		return b
	var t := (edge_x - a.x) / dir.x
	return a + dir * t


func _skirt(prev: Vector2, corner: Vector2, nxt: Vector2, dist: float) -> Vector2:
	var a := prev - corner
	var b := nxt - corner
	if a.length() < 0.001 or b.length() < 0.001:
		return corner
	var bis := a.normalized() + b.normalized()
	if bis.length() < 0.01:
		bis = Vector2(-(nxt - prev).y, (nxt - prev).x)
	bis = bis.normalized()
	if bis.dot(corner - (HOME_BASE + SECOND_BASE) * 0.5) < 0.0:
		bis = -bis
	return corner + bis * dist


func _stroke(a: Vector2, b: Vector2, color: Color, width: float, overshoot: float) -> void:
	var dir := b - a
	var len := dir.length()
	if len < 0.001:
		return
	dir /= len
	draw_line(a - dir * overshoot, b + dir * overshoot, color, width, true)


func _field_to_screen(fx: float, fy: float) -> Vector2:
	return Vector2(640.0 + fx * (380.0 / 63.64), HOME_BASE.y - fy * (150.0 / 63.64))


func _infield_skin() -> PackedVector2Array:
	var pts := PackedVector2Array()
	pts.append(_field_to_screen(-3.0, -3.0))
	for i in 38:
		var ang := deg_to_rad(lerpf(-77.0, 77.0, float(i) / 37.0))
		pts.append(_field_to_screen(85.0 * sin(ang), 60.5 + 85.0 * cos(ang)))
	pts.append(_field_to_screen(3.0, -3.0))
	return pts


func _batter_box(side: float) -> PackedVector2Array:
	var inner := 640.0 + 54.0 * side
	var outer := 640.0 + 200.0 * side
	var top := 556.0
	var bottom := 652.0
	return PackedVector2Array([
		Vector2(inner, top),
		Vector2(outer, top),
		Vector2(outer, bottom),
		Vector2(inner, bottom),
	])


func _fill_ellipse(center: Vector2, rx: float, ry: float, color: Color) -> void:
	var pts := PackedVector2Array()
	for i in 28:
		var ang := TAU * float(i) / 28.0
		pts.append(center + Vector2(cos(ang) * rx, sin(ang) * ry))
	draw_colored_polygon(pts, color)


func _bag(at: Vector2, prev: Vector2, nxt: Vector2, size: float) -> void:
	var a := (prev - at).normalized() * size
	var b := (nxt - at).normalized() * size
	var center := at - (a + b) * 0.5
	var top := PackedVector2Array([center + (a + b) * 0.5, center + (a - b) * 0.5, center - (a + b) * 0.5, center + (b - a) * 0.5])
	var lift := Vector2(0, 3)
	var lowest := 0
	for i in top.size():
		if top[i].y > top[lowest].y:
			lowest = i
	var l := top[(lowest + 3) % 4]
	var r := top[(lowest + 1) % 4]
	var bottom := top[lowest]
	draw_colored_polygon(PackedVector2Array([l, bottom, bottom + lift, l + lift]), Color(0.74, 0.75, 0.78))
	draw_colored_polygon(PackedVector2Array([bottom, r, r + lift, bottom + lift]), Color(0.66, 0.67, 0.71))
	draw_colored_polygon(top, Color(0.97, 0.97, 0.98))
	var edge := top.duplicate()
	edge.append(top[0])
	draw_polyline(edge, Color(0.7, 0.71, 0.75), 1.2, true)


func _home_plate(at: Vector2) -> void:
	var plate := PackedVector2Array([
		at + Vector2(-20, -8),
		at + Vector2(20, -8),
		at + Vector2(20, 8),
		at + Vector2(0, 24),
		at + Vector2(-20, 8),
	])
	draw_colored_polygon(plate, Color(0.97, 0.97, 0.96))
	var ink := Color(0.15, 0.13, 0.12)
	for i in plate.size():
		_stroke(plate[i], plate[(i + 1) % plate.size()], ink, 2.0, 1.0)


func _chalk_box(pts: PackedVector2Array) -> void:
	var closed := pts.duplicate()
	closed.append(pts[0])
	draw_polyline(closed, Color(0.96, 0.97, 0.95), 2.5, true)


func _draw_sky() -> void:
	draw_polygon(PackedVector2Array([
		Vector2(0, 0), Vector2(1280, 0), Vector2(1280, 150), Vector2(0, 150),
	]), PackedColorArray([
		Color(0.33, 0.58, 0.90),
		Color(0.33, 0.58, 0.90),
		Color(0.68, 0.85, 0.97),
		Color(0.68, 0.85, 0.97),
	]))


func _draw_outfield() -> void:
	draw_rect(Rect2(0, 166, 1280, 554), Color(0.15, 0.46, 0.19))
	for i in 11:
		var t0 := float(i) / 11.0
		var t1 := float(i + 1) / 11.0
		var y0 := lerpf(168.0, 720.0, pow(t0, 1.4))
		var y1 := lerpf(168.0, 720.0, pow(t1, 1.4))
		if i % 2 == 0:
			continue
		var shade := Color(0.10, 0.34, 0.14).lerp(Color(0.22, 0.58, 0.26), t0)
		draw_rect(Rect2(0, minf(y0, y1), 1280, maxf(absf(y1 - y0), 1.0)), shade)
	draw_rect(Rect2(0, 150, 1280, 18), Color(0.62, 0.49, 0.31))
	draw_rect(Rect2(0, 150, 1280, 3), Color(0.78, 0.64, 0.42))


func _draw_stands() -> void:
	draw_rect(Rect2(0, 0, 1280, 34), Color(0.07, 0.09, 0.12))
	draw_rect(Rect2(0, 34, 1280, 84), Color(0.13, 0.15, 0.20))
	var shirts: Array[Color] = [
		Color(0.86, 0.22, 0.18), Color(0.95, 0.78, 0.28), Color(0.22, 0.42, 0.86),
		Color(0.93, 0.94, 0.96), Color(0.16, 0.52, 0.36),
	]
	var skins: Array[Color] = [Color(0.93, 0.76, 0.58), Color(0.62, 0.42, 0.28), Color(0.36, 0.24, 0.16)]
	var bob := Time.get_ticks_msec() * 0.004
	for row in 3:
		var count := 30 - row * 2
		var gap := 1280.0 / float(count)
		var y := 48.0 + float(row) * 20.0
		var rad := 4.4 - float(row) * 0.4
		for i in count:
			var at := Vector2(gap * (float(i) + 0.5), y + sin(bob + float(i + row * 5)) * 1.1)
			var shirt: Color = shirts[(i + row * 2) % shirts.size()]
			draw_circle(at + Vector2(0, rad + 2.0), rad + 1.4, shirt)
			var skin: Color = skins[(i + row) % skins.size()]
			draw_circle(at, rad, skin)
			draw_circle(at + Vector2(0, -1.4), rad + 0.5, shirt.darkened(0.42))
	draw_rect(Rect2(0, 112, 1280, 38), Color(0.07, 0.32, 0.16))
	draw_rect(Rect2(0, 112, 1280, 5), Color(0.95, 0.82, 0.34))
	for i in 9:
		draw_line(Vector2(float(i) * 142.0, 117), Vector2(float(i) * 142.0, 150), Color(0, 0, 0, 0.18), 2.0)


func _draw_fielders(behind: bool) -> void:
	var order: Array[int] = []
	for i in FIELDERS.size():
		order.append(i)
	order.sort_custom(func(a: int, b: int) -> bool: return _fielder_spot(a).y < _fielder_spot(b).y)
	for n in order:
		var i := int(n)
		var at := _fielder_spot(i)
		if (at.y < 300.0) != behind:
			continue
		var running := phase == "hit" and phase_t < hit_flight and (i == play_idx or i == cover_idx)
		var aim := Vector2.ZERO
		if phase == "hit" and i == play_idx and throw_base >= 0 and phase_t >= hit_flight and phase_t < hit_flight + 0.1 + throw_dur * 0.45:
			aim = throw_to
		var name := str(FIELDERS[i]["name"])
		var infield := name != "LF" and name != "CF" and name != "RF"
		_paint_fielder(at, running, aim, infield)


func _kit(home: bool) -> Dictionary:
	if home:
		return {
			"jersey": Color(0.08, 0.08, 0.1),
			"pants": Color(0.95, 0.78, 0.16),
			"cap": Color(0.08, 0.08, 0.1),
			"brim": Color(0.95, 0.78, 0.16),
			"trim": Color(0.95, 0.78, 0.16),
			"helmet": Color(0.08, 0.08, 0.1),
		}
	return {
		"jersey": Color(0.96, 0.96, 0.97),
		"pants": Color(0.92, 0.92, 0.94),
		"cap": Color(0.72, 0.1, 0.14),
		"brim": Color(0.72, 0.1, 0.14),
		"trim": Color(0.72, 0.1, 0.14),
		"helmet": Color(0.72, 0.1, 0.14),
	}


func _fielding_kit() -> Dictionary:
	return _kit(phase == "opponent")


func _batting_kit() -> Dictionary:
	return _kit(phase != "opponent")


func _torso(hip: Vector2, shoulder: Vector2, hip_w: float, shoulder_w: float, kit: Dictionary, outline_w: float) -> void:
	var pts := PackedVector2Array([
		hip + Vector2(-hip_w, 0),
		hip + Vector2(hip_w, 0),
		shoulder + Vector2(shoulder_w, 0),
		shoulder + Vector2(-shoulder_w, 0),
	])
	draw_colored_polygon(pts, kit["jersey"])
	var closed := pts.duplicate()
	closed.append(pts[0])
	draw_polyline(closed, Color(0.08, 0.07, 0.1), outline_w, true)
	draw_line(hip + Vector2(-hip_w, 0), hip + Vector2(hip_w, 0), kit["pants"], outline_w * 1.4, true)


func _head_front(center: Vector2, r: float, kit: Dictionary) -> void:
	var skin := Color(0.93, 0.76, 0.58)
	var ink := Color(0.08, 0.07, 0.1)
	draw_circle(center, r, skin)
	var cap := PackedVector2Array()
	for i in 13:
		var ang := PI + PI * float(i) / 12.0
		cap.append(center + Vector2(0, -0.08 * r) + Vector2(cos(ang), sin(ang)) * r * 1.04)
	draw_colored_polygon(cap, kit["cap"])
	_fill_ellipse(center + Vector2(0, -0.04 * r), r * 1.2, r * 0.26, kit["brim"])
	draw_circle(center + Vector2(-0.34 * r, 0.32 * r), maxf(r * 0.13, 0.9), ink)
	draw_circle(center + Vector2(0.34 * r, 0.32 * r), maxf(r * 0.13, 0.9), ink)


func _head_back(center: Vector2, r: float, kit: Dictionary, side: float) -> void:
	var skin := Color(0.93, 0.76, 0.58)
	var shell: Color = kit["helmet"]
	var ink := Color(0.08, 0.07, 0.1)
	draw_circle(center + Vector2(0.3 * r * side, 0.2 * r), r * 0.78, skin)
	draw_circle(center + Vector2(-0.08 * r * side, -0.04 * r), r, shell)
	draw_arc(center + Vector2(-0.08 * r * side, -0.04 * r), r, 0, TAU, 18, ink, 1.6, true)
	draw_circle(center + Vector2(0.62 * r * side, 0.42 * r), r * 0.4, shell)
	draw_line(center + Vector2(0.45 * r * side, -0.5 * r), center + Vector2(1.2 * r * side, -0.12 * r), kit["brim"], r * 0.22, true)


func _head_side(center: Vector2, r: float, kit: Dictionary, look: float) -> void:
	var skin := Color(0.93, 0.76, 0.58)
	var shell: Color = kit["helmet"]
	var ink := Color(0.08, 0.07, 0.1)
	draw_circle(center + Vector2(0.22 * r * look, 0.18 * r), r * 0.82, skin)
	draw_circle(center + Vector2(-0.12 * r * look, -0.1 * r), r, shell)
	draw_arc(center + Vector2(-0.12 * r * look, -0.1 * r), r, 0, TAU, 16, ink, 1.4, true)
	draw_circle(center + Vector2(-0.55 * r * look, 0.42 * r), r * 0.38, shell)
	draw_line(center + Vector2(0.35 * r * look, -0.28 * r), center + Vector2(1.3 * r * look, 0.02 * r), kit["brim"], r * 0.22, true)
	draw_circle(center + Vector2(0.62 * r * look, 0.22 * r), maxf(r * 0.12, 0.9), ink)


func _paint_fielder(feet: Vector2, running: bool, aim: Vector2, infield: bool) -> void:
	var sc := _fielder_scale(feet.y)
	var kit := _fielding_kit()
	var stride := sin(Time.get_ticks_msec() * 0.016) * 8.0 * sc if running else 0.0
	var set := infield and not running
	var knee := 6.0 * sc if set else 0.0
	var spread := (9.0 if set else 5.5) * sc
	var skin := Color(0.93, 0.76, 0.58)
	var pants: Color = kit["pants"]
	var hip := feet + Vector2(0, -20.0 * sc + knee)
	var shoulder := hip + Vector2(0, -15.0 * sc)
	_fill_ellipse(feet + Vector2(0, 2), 12 * sc, 4.5 * sc, Color(0, 0, 0, 0.24))
	draw_line(hip + Vector2(-4, 0) * sc, feet + Vector2(-spread - stride, 0), pants, maxf(4.4 * sc, 2.0), true)
	draw_line(hip + Vector2(4, 0) * sc, feet + Vector2(spread + stride, 0), pants, maxf(4.4 * sc, 2.0), true)
	_torso(hip, shoulder, 6.0 * sc, 8.5 * sc, kit, maxf(1.2 * sc, 1.0))
	var throw_sh := shoulder + Vector2(7, 1) * sc
	var hand := throw_sh + Vector2(5, 12) * sc
	if aim != Vector2.ZERO:
		var dir := aim - throw_sh
		if dir.length() > 1.0:
			hand = throw_sh + dir.normalized() * 15.0 * sc
	draw_line(throw_sh, hand, skin, maxf(2.6 * sc, 1.6), true)
	draw_circle(hand, maxf(2.0 * sc, 1.4), skin)
	var glove_sh := shoulder + Vector2(-7, 1) * sc
	var glove := hip + (Vector2(-6, 9) * sc if set else Vector2(-9, 4) * sc)
	draw_line(glove_sh, glove, skin, maxf(2.6 * sc, 1.6), true)
	_glove(glove, 6.5 * sc, 1.0, 0.25 if set else -0.15, true, false)
	var head := shoulder + Vector2(0, -8.5 * sc)
	draw_line(shoulder, head, skin, maxf(3.0 * sc, 1.6), true)
	_head_front(head, 6.2 * sc, kit)


func _draw_pitcher() -> void:
	var origin := RUBBER + Vector2(0, -28)
	var sc := 0.8
	var kit := _fielding_kit()
	var arm := -0.4
	if phase == "windup":
		arm = lerpf(-0.35, -1.7, clampf(phase_t / 0.42, 0.0, 1.0))
	elif phase == "flight":
		arm = lerpf(1.2, 0.45, clampf(phase_t / pitch_dur, 0.0, 1.0))
	elif phase == "whiff":
		if whiff_wind > 0.0:
			arm = lerpf(-0.35, -1.7, clampf((0.42 - whiff_wind) / 0.42, 0.0, 1.0))
		else:
			arm = lerpf(1.2, 0.45, clampf(ball_t / pitch_dur, 0.0, 1.0))
	elif phase == "opponent" and opp_active:
		if opp_t < 0.2:
			arm = lerpf(-0.35, -1.7, clampf(opp_t / 0.2, 0.0, 1.0))
		else:
			arm = lerpf(1.2, 0.45, clampf((opp_t - 0.2) / 0.3, 0.0, 1.0))
	var skin := Color(0.93, 0.76, 0.58)
	var pants: Color = kit["pants"]
	var hip := origin + Vector2(0, 8) * sc
	var shoulder := origin + Vector2(0, -13) * sc
	var throw_sh := shoulder + Vector2(-10, 2) * sc
	hand_pos = throw_sh + Vector2(cos(arm), sin(arm)) * 34.0 * sc
	_fill_ellipse(origin + Vector2(0, 38) * sc, 18 * sc, 6 * sc, Color(0, 0, 0, 0.28))
	draw_line(hip + Vector2(-5, 0) * sc, origin + Vector2(-12, 34) * sc, pants, 7.0, true)
	draw_line(hip + Vector2(5, 0) * sc, origin + Vector2(13, 34) * sc, pants, 7.0, true)
	draw_line(origin + Vector2(-12, 34) * sc, origin + Vector2(-4, 36) * sc, Color(0.12, 0.1, 0.1), 3.5, true)
	draw_line(origin + Vector2(13, 34) * sc, origin + Vector2(21, 35) * sc, Color(0.12, 0.1, 0.1), 3.5, true)
	_torso(hip, shoulder, 9.0 * sc, 12.5 * sc, kit, 1.6)
	draw_line(throw_sh, hand_pos, skin, 5.0, true)
	draw_circle(hand_pos, 4.0, skin)
	var glove := origin + Vector2(17, 6) * sc
	draw_line(shoulder + Vector2(10, 2) * sc, glove, skin, 5.0, true)
	_glove(glove, 8.0, -1.0, 0.2, true, false)
	var head := shoulder + Vector2(0, -13) * sc
	draw_line(shoulder, head, skin, 5.0, true)
	_head_front(head, 9.5 * sc, kit)


func _draw_zone(frame: Color) -> void:
	var ink := Color(0.1, 0.08, 0.12)
	var arm := 14.0
	var corners: Array[Vector2] = [zone.position, Vector2(zone.end.x, zone.position.y), zone.end, Vector2(zone.position.x, zone.end.y)]
	var dirs: Array[Vector2] = [Vector2(1, 1), Vector2(-1, 1), Vector2(-1, -1), Vector2(1, -1)]
	for i in 4:
		var corner: Vector2 = corners[i]
		var dir: Vector2 = dirs[i]
		draw_line(corner, corner + Vector2(dir.x * arm, 0), ink, 8.0)
		draw_line(corner, corner + Vector2(0, dir.y * arm), ink, 8.0)
		draw_line(corner, corner + Vector2(dir.x * arm, 0), frame, 4.0)
		draw_line(corner, corner + Vector2(0, dir.y * arm), frame, 4.0)
		draw_circle(corner, 2.4, frame)


func _mirror_plate(point: Vector2, side: float) -> Vector2:
	if side > 0.0:
		return point
	return Vector2(1280.0 - point.x, point.y)


func _draw_batter() -> void:
	if phase == "opponent":
		_draw_batter_figure(opp_lefty, opp_swing_p, _batting_kit(), false, Vector2.ZERO)
		return
	_draw_batter_figure(lefty[batter_i] == true, swing_p, _batting_kit(), meet, meet_pos)


func _draw_batter_figure(is_left: bool, p: float, kit: Dictionary, meet_on: bool, meet_at: Vector2) -> void:
	var side := -1.0 if is_left else 1.0
	var root := Vector2(760, 574) if is_left else Vector2(520, 574)
	var slot_u := _smooth(clampf(p / 0.36, 0.0, 1.0))
	var fire := _smooth(clampf((p - 0.28) / 0.72, 0.0, 1.0))
	var through := _smooth(clampf((p - 0.9) / 0.28, 0.0, 1.0))
	var finish := _smooth(clampf((p - 1.02) / 0.38, 0.0, 1.0))
	var heel := clampf(fire * 1.15, 0.0, 1.0)
	var ink := Color(0.08, 0.07, 0.1)
	var skin := Color(0.93, 0.76, 0.58)
	var jersey: Color = kit["jersey"]
	var pants: Color = kit["pants"]
	var cleat := Color(0.12, 0.1, 0.1)
	var hand_r := Vector2(500, 472).lerp(Vector2(518, 504), slot_u)
	hand_r = hand_r.lerp(Vector2(556, 512), fire)
	hand_r = hand_r.lerp(Vector2(548, 446), finish)
	var tip_r := Vector2(462, 388).lerp(Vector2(448, 486), slot_u)
	tip_r = tip_r.lerp(Vector2(655, 502), fire)
	tip_r = tip_r.lerp(Vector2(770, 460), through)
	tip_r = tip_r.lerp(Vector2(590, 388), finish)
	var grip := _mirror_plate(hand_r, side) + Vector2(0, 40)
	var tip := _mirror_plate(tip_r, side) + Vector2(0, 40)
	if meet_on and p > 0.72 and p < 1.22:
		var on_ball := 1.0 - absf(p - 1.0) / 0.28
		tip = tip.lerp(meet_at, clampf(on_ball, 0.0, 1.0))
	var barrel := tip - grip
	if barrel.length_squared() < 4.0:
		barrel = Vector2(side, -0.2)
	var stride := _smooth(clampf(p / 0.4, 0.0, 1.0))
	var open := fire
	var back_foot := root + Vector2(-16.0 * side, 48.0)
	var toe := back_foot + Vector2(-6.0 * side, -8.0 * heel)
	var lead_foot := root + Vector2(20.0 * side, lerpf(40.0, 22.0, stride))
	var hip := root + Vector2(1.0 * side * open, 8.0)
	var hip_back := hip + Vector2(-11.0 * side, 2.0)
	var hip_lead := hip + Vector2(11.0 * side, 0.0)
	var back_knee := hip_back.lerp(toe, 0.5) + Vector2(6.0 * side * (1.0 - heel * 0.4), -4.0)
	var lead_knee := hip_lead.lerp(lead_foot, 0.5) + Vector2(4.0 * side, 3.0) * (1.0 - open)
	var shoulder_y := hip + Vector2(2.0 * side * open, -40.0)
	var lead_shoulder := shoulder_y + Vector2(lerpf(8.0, 15.0, open) * side, lerpf(-4.0, 0.0, open))
	var back_shoulder := shoulder_y + Vector2(lerpf(-9.0, -14.0, open) * side, lerpf(5.0, 1.0, open))
	var crown := (lead_shoulder + back_shoulder) * 0.5
	var head := Vector2(root.x, crown.y - 20.0)
	var back_elbow := back_shoulder.lerp(grip, 0.42) + Vector2(-6.0 * side, 10.0) * (1.0 - open)
	var lead_elbow := lead_shoulder.lerp(grip, 0.48) + Vector2(0.0, 7.0) * (1.0 - open * 0.75)
	_fill_ellipse((back_foot + lead_foot) * 0.5 + Vector2(0, 6), 24, 7, Color(0, 0, 0, 0.3))
	draw_line(hip_back, back_knee, pants, 11.0, true)
	draw_line(back_knee, toe, pants, 9.0, true)
	draw_line(toe, toe + Vector2(-8.0 * side, 3.0), cleat, 4.0, true)
	draw_line(hip_lead, lead_knee, pants, 11.0, true)
	draw_line(lead_knee, lead_foot, pants, 9.0, true)
	draw_line(lead_foot, lead_foot + Vector2(6.0 * side, -7.0), cleat, 4.0, true)
	draw_line(back_shoulder, back_elbow, skin, 7.5, true)
	draw_line(back_elbow, grip, skin, 6.5, true)
	draw_colored_polygon(PackedVector2Array([hip_back, hip_lead, lead_shoulder, back_shoulder]), jersey)
	draw_polyline(PackedVector2Array([hip_back, hip_lead, lead_shoulder, back_shoulder, hip_back]), ink, 2.0, true)
	draw_line(hip_back, hip_lead, pants, 3.0, true)
	var collar: Color = kit["trim"]
	draw_line(back_shoulder.lerp(lead_shoulder, 0.22) + Vector2(0, 6), back_shoulder.lerp(lead_shoulder, 0.78) + Vector2(0, 6), collar, 3.0, true)
	draw_circle(back_shoulder, 5.5, jersey.darkened(0.08))
	draw_circle(lead_shoulder, 5.5, jersey.darkened(0.08))
	draw_line(crown, head + Vector2(0, 11), skin, 6.5, true)
	_head_back(head, 13.0, kit, side)
	draw_line(lead_shoulder, lead_elbow, skin, 7.5, true)
	draw_line(lead_elbow, grip, skin, 6.5, true)
	var bat_scale := barrel.length() / (BAT_TEX.get_height() * 0.92)
	draw_set_transform(grip, barrel.angle() + PI * 0.5, Vector2(bat_scale, bat_scale))
	draw_texture(BAT_TEX, Vector2(-BAT_TEX.get_width() * 0.5, -BAT_TEX.get_height() * 0.92))
	draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
	draw_circle(grip, 5.0, skin)


func _base_at(n: int) -> Vector2:
	if n == 1:
		return FIRST_BASE
	if n == 2:
		return SECOND_BASE
	if n == 3:
		return THIRD_BASE
	return HOME_BASE


func _draw_baserunners() -> void:
	if phase == "opponent":
		for slot in [1, 0, 2]:
			if opp_on[slot] != true:
				continue
			var bag: int = slot + 1
			var off := Vector2(18, 12) if slot == 0 else (Vector2(-18, 12) if slot == 2 else Vector2(0, -20))
			_paint_runner(_base_at(bag) + off, false, _faces_left(bag), false)
		return
	if phase == "hit" or phase == "trot":
		var run_t := clampf((phase_t - 0.12) / 0.74, 0.0, float(trot_span))
		for m in movers:
			var start_b := int(m["start"])
			var dest_b := int(m["dest"])
			var span := dest_b - start_b
			var traveled := clampf(run_t, 0.0, float(span))
			var at := _along_bases(start_b, dest_b, traveled)
			var step := mini(start_b + int(traveled), dest_b - 1)
			_paint_runner(at, traveled < float(span), _faces_left(step), m["power"] == true)
	else:
		for slot in [1, 0, 2]:
			if int(on_base[slot]) == -1:
				continue
			var bag: int = slot + 1
			var off := Vector2(18, 12) if slot == 0 else (Vector2(-18, 12) if slot == 2 else Vector2(0, -20))
			_paint_runner(_base_at(bag) + off, false, _faces_left(bag), base_power[slot] == true)


func _along_bases(start_b: int, dest_b: int, traveled: float) -> Vector2:
	if dest_b <= start_b:
		return _base_at(dest_b)
	if traveled >= float(dest_b - start_b):
		return _base_at(0 if dest_b >= 4 else dest_b)
	var step := start_b + int(traveled)
	var along := traveled - float(int(traveled))
	along = along * along * (3.0 - 2.0 * along)
	var nxt := 0 if step + 1 >= 4 else step + 1
	return _base_at(step).lerp(_base_at(nxt), along)


func _faces_left(base_i: int) -> bool:
	var nxt := 0 if base_i + 1 >= 4 else base_i + 1
	return _base_at(nxt).x < _base_at(base_i).x


func _paint_runner(at: Vector2, running: bool, face_left: bool, _powered: bool) -> void:
	var sc := clampf((at.y - 100.0) / 560.0, 0.5, 0.92)
	var look := -1.0 if face_left else 1.0
	var kit := _batting_kit()
	var stride := sin(Time.get_ticks_msec() * 0.016) * 16.0 if running else 0.0
	var pants: Color = kit["pants"]
	var skin := Color(0.93, 0.76, 0.58)
	draw_set_transform(at, 0.0, Vector2(sc, sc))
	var hip := Vector2(look * 2.0, -30.0)
	var shoulder := hip + Vector2(look * 2.0, -24.0)
	_fill_ellipse(Vector2(0, -2), 16, 6, Color(0, 0, 0, 0.28))
	draw_line(hip + Vector2(-5, 0), Vector2(-12 - stride, -4), pants, 8.0, true)
	draw_line(hip + Vector2(5, 0), Vector2(12 + stride, -4), pants, 8.0, true)
	draw_line(Vector2(-12 - stride, -4), Vector2(-4 - stride, -1), Color(0.1, 0.08, 0.08), 3.0, true)
	draw_line(Vector2(12 + stride, -4), Vector2(20 + stride, -2), Color(0.1, 0.08, 0.08), 3.0, true)
	var back_arm := shoulder + Vector2(-9.0 * look, 2.0)
	draw_line(back_arm, back_arm + Vector2(-6.0 * look - stride * 0.5, 16.0), skin, 5.0, true)
	_torso(hip, shoulder, 9.0, 12.0, kit, 1.8)
	var front_arm := shoulder + Vector2(9.0 * look, 2.0)
	draw_line(front_arm, front_arm + Vector2(8.0 * look + stride * 0.5, 12.0), skin, 5.0, true)
	var head := shoulder + Vector2(look * 1.0, -14.0)
	draw_line(shoulder, head, skin, 6.0, true)
	_head_side(head, 11.0, kit, look)
	draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)


func _catcher_glove(origin: Vector2) -> Vector2:
	var rest := origin + Vector2(34, -30)
	if phase == "opponent":
		if not opp_active or opp_t < 0.2:
			return rest
		var u := clampf((opp_t - 0.2) / 0.3, 0.0, 1.0)
		var opp_dest := opp_target
		opp_dest.x = clampf(opp_dest.x, origin.x - 72.0, origin.x + 72.0)
		opp_dest.y = clampf(opp_dest.y, origin.y - 156.0, origin.y - 16.0)
		if opp_t > 0.7:
			return opp_dest.lerp(rest, clampf((opp_t - 0.7) / 0.3, 0.0, 1.0))
		return rest.lerp(opp_dest, _smooth(u))
	if pitch.is_empty() or phase == "windup" or phase == "hit" or phase == "trot" or phase == "ready" or phase == "final":
		return rest
	if phase == "whiff" and whiff_wind > 0.0:
		return rest
	var travel := 1.0
	if phase == "flight":
		travel = clampf(phase_t / pitch_dur, 0.0, 1.0)
	elif phase == "whiff":
		travel = clampf(ball_t / pitch_dur, 0.0, 1.0)
	var dest := target
	if phase == "flight" or phase == "whiff":
		dest = _ball_pos(minf(travel, 1.0))
	dest.x = clampf(dest.x, origin.x - 72.0, origin.x + 72.0)
	dest.y = clampf(dest.y, origin.y - 156.0, origin.y - 16.0)
	if phase == "show" or phase == "gameover":
		if phase_t <= 0.16:
			return dest
		return dest.lerp(rest, clampf((phase_t - 0.16) / 0.3, 0.0, 1.0))
	var reach := _smooth(clampf((travel - 0.35) / 0.55, 0.0, 1.0))
	return rest.lerp(dest, reach)


func _draw_catcher() -> void:
	var origin := HOME_BASE + Vector2(0, 76)
	var kit := _fielding_kit()
	var ink := Color(0.08, 0.07, 0.1)
	var skin := Color(0.93, 0.76, 0.58)
	var pants: Color = kit["pants"]
	var strap := Color(0.6, 0.62, 0.68)
	var glove := _catcher_glove(origin)
	var glove_side := 1.0 if glove.x >= origin.x else -1.0
	var hip := origin + Vector2(0, 12)
	var shoulder := origin + Vector2(0, -16)
	var glove_sh := shoulder + Vector2(18.0 * glove_side, 2.0)
	draw_line(glove_sh, glove, skin, 7.0, true)
	_glove(glove, 19.0, -glove_side, 0.0, false, true)
	_fill_ellipse(origin + Vector2(0, 30), 30, 9, Color(0, 0, 0, 0.32))
	draw_line(hip + Vector2(-14, 0), origin + Vector2(-26, 30), pants, 12.0, true)
	draw_line(hip + Vector2(14, 0), origin + Vector2(26, 30), pants, 12.0, true)
	draw_line(hip + Vector2(-13, 2), origin + Vector2(-24, 26), strap, 3.0, true)
	draw_line(hip + Vector2(13, 2), origin + Vector2(24, 26), strap, 3.0, true)
	draw_line(origin + Vector2(-26, 30), origin + Vector2(-16, 34), Color(0.1, 0.08, 0.08), 4.0, true)
	draw_line(origin + Vector2(26, 30), origin + Vector2(36, 32), Color(0.1, 0.08, 0.08), 4.0, true)
	_torso(hip, shoulder, 22.0, 26.0, kit, 2.2)
	draw_line(shoulder + Vector2(-16, 2), hip + Vector2(12, -4), strap, 3.0, true)
	draw_line(shoulder + Vector2(16, 2), hip + Vector2(-12, -4), strap, 3.0, true)
	var bare_sh := shoulder + Vector2(-18.0 * glove_side, 2.0)
	draw_line(bare_sh, hip + Vector2(-20.0 * glove_side, 6.0), skin, 6.5, true)
	var head := shoulder + Vector2(0, -20)
	draw_line(shoulder, head, skin, 8.0, true)
	draw_circle(head, 15, kit["helmet"])
	draw_arc(head, 15, 0, TAU, 16, ink, 2.0, true)
	draw_line(head + Vector2(-10, -3), head + Vector2(10, 7), strap, 2.4, true)
	draw_line(head + Vector2(10, -3), head + Vector2(-10, 7), strap, 2.4, true)
	draw_line(head + Vector2(-13, 3), head + Vector2(13, 3), strap, 2.0, true)


func _draw_ball() -> void:
	if phase == "opponent":
		_draw_opp_ball()
		return
	if pitch.is_empty() or phase == "ready" or phase == "final":
		return
	var travel := 0.0
	var radius := 7.0
	if phase == "windup":
		ball = hand_pos
	elif phase == "flight" or (phase == "whiff" and whiff_wind <= 0.0):
		var clock := phase_t if phase == "flight" else ball_t
		travel = clampf(clock / pitch_dur, 0.0, 1.0)
		ball = _ball_pos(travel)
		radius = lerpf(7.0, 24.0, travel)
	elif phase == "whiff":
		ball = hand_pos
	elif phase == "hit":
		var release := hit_flight + 0.1
		var arrive := release + throw_dur
		if throw_base == -2 and phase_t > hit_flight:
			ball = hit_to
			radius = 6.5
		elif phase_t <= hit_flight or throw_base == -1:
			var u := clampf(phase_t / maxf(hit_flight, 0.05), 0.0, 1.0)
			var eased := 1.0 - (1.0 - u) * (1.0 - u)
			ball = hit_from.lerp(hit_to, eased)
			ball.y -= sin(minf(u, 1.0) * PI) * hit_arc
			radius = lerpf(hit_radius, 6.0, eased)
			if throw_base == -1 and phase_t > hit_flight + 0.2:
				radius = 0.0
		elif phase_t <= release:
			ball = throw_from
			radius = 7.0
		elif phase_t <= arrive:
			var u := clampf((phase_t - release) / maxf(throw_dur, 0.05), 0.0, 1.0)
			ball = throw_from.lerp(throw_to, 1.0 - (1.0 - u) * (1.0 - u))
			ball.y -= sin(u * PI) * lerpf(16.0, 36.0, clampf(throw_dur, 0.0, 1.0))
			radius = lerpf(6.0, 12.0, clampf((ball.y - 150.0) / 460.0, 0.0, 1.0))
		else:
			ball = throw_to
			radius = 8.0
	else:
		var hold := 0.16
		var dur := 0.42
		var from := target
		var to := hand_pos
		if phase_t <= hold:
			ball = from
			radius = 16.0
		else:
			var u := clampf((phase_t - hold) / dur, 0.0, 1.0)
			var eased := _smooth(u)
			ball = from.lerp(to, eased)
			ball.y -= sin(u * PI) * 22.0
			radius = lerpf(16.0, 7.0, eased)
	if radius < 0.5:
		return
	var shadow_at := ball
	var shadow_rx := radius * 0.62
	var shadow_ry := maxf(radius * 0.24, 2.0)
	var shadow_a := 0.3
	if phase == "hit" and phase_t <= hit_flight:
		var lift := sin(clampf(phase_t / maxf(hit_flight, 0.05), 0.0, 1.0) * PI)
		shadow_at = Vector2(ball.x, ball.y + lift * hit_arc)
		shadow_rx = lerpf(radius * 0.55, radius * 0.32, lift)
		shadow_ry = maxf(shadow_rx * 0.36, 2.0)
		shadow_a = lerpf(0.32, 0.12, lift)
	_fill_ellipse(shadow_at + Vector2(0, 4), shadow_rx, shadow_ry, Color(0, 0, 0, shadow_a))
	draw_texture_rect(BALL_TEX, Rect2(ball.x - radius, ball.y - radius, radius * 2.0, radius * 2.0), false)
	var tint := _timing_tint()
	if tint.a > 0.01:
		draw_circle(ball, radius * 0.92, tint)


func _draw_opp_ball() -> void:
	if not opp_active:
		return
	var radius := 7.0
	var contact := 0.5
	var kind := str(opp_result.get("kind", "out"))
	if opp_t < 0.2:
		ball = hand_pos
	elif opp_t < contact:
		var u := clampf((opp_t - 0.2) / 0.3, 0.0, 1.0)
		ball = start.lerp(opp_target, u)
		radius = lerpf(7.0, 22.0, u)
	elif kind == "walk" or kind == "strikeout":
		ball = opp_target
		radius = 16.0
	else:
		var u := clampf((opp_t - contact) / 0.42, 0.0, 1.0)
		var eased := 1.0 - (1.0 - u) * (1.0 - u)
		ball = opp_target.lerp(opp_hit_to, eased)
		ball.y -= sin(u * PI) * opp_hit_arc
		radius = lerpf(16.0, 6.0, eased)
		if kind == "home" and u >= 1.0:
			return
		_fill_ellipse(Vector2(ball.x, ball.y + sin(u * PI) * opp_hit_arc + 4.0), radius * 0.5, maxf(radius * 0.2, 2.0), Color(0, 0, 0, 0.25))
	draw_texture_rect(BALL_TEX, Rect2(ball.x - radius, ball.y - radius, radius * 2.0, radius * 2.0), false)


func _timing_tint() -> Color:
	var clock := -1.0
	if phase == "flight":
		clock = phase_t
	elif phase == "whiff" and whiff_wind <= 0.0:
		clock = ball_t
	if clock < 0.0:
		return Color(0, 0, 0, 0)
	var edge := 0.17 if power_mode else 0.25
	var ideal := pitch_dur - SWING_DELAY
	if absf(clock - ideal) >= edge - 0.02:
		return Color(0, 0, 0, 0)
	if zone.has_point(target):
		return Color(0.9, 0.08, 0.06, 0.5)
	return Color(0.15, 0.45, 0.95, 0.5)


func _glove(center: Vector2, r: float, thumb: float, tilt: float, pocket: bool, mitt: bool) -> void:
	r = maxf(r, 3.0)
	var leather := Color(0.58, 0.36, 0.17)
	var dark := Color(0.4, 0.23, 0.1)
	var ink := Color(0.16, 0.1, 0.05)
	var lace := Color(0.82, 0.66, 0.38)
	var outline := maxf(r * 0.12, 1.0)
	var finger_r := r * (0.42 if mitt else 0.36)
	var finger_d := r * (0.84 if mitt else 0.86)
	var fingers: Array[Vector2] = []
	var count := 3 if mitt else 4
	for i in count:
		var span := 1.5 if mitt else 1.7
		var ang := -PI * 0.5 + tilt + (float(i) - float(count - 1) * 0.5) * span / float(count)
		fingers.append(center + Vector2(cos(ang), sin(ang)) * finger_d)
	var thumb_at := center + Vector2(cos(tilt) * thumb, sin(tilt) * 0.25 + 0.15) * r * 0.95
	var thumb_r := r * 0.44
	# Outline pass, then fill pass, so the lobes read as one piece of leather.
	for pass_i in 2:
		var col := ink if pass_i == 0 else leather
		var grow := outline if pass_i == 0 else 0.0
		draw_circle(center, r + grow, col)
		for f in fingers:
			draw_circle(f, finger_r + grow, col)
		draw_circle(thumb_at, thumb_r + grow, col)
		if pass_i == 0:
			continue
		var near: Vector2 = fingers[count - 1] if thumb > 0.0 else fingers[0]
		var web := PackedVector2Array([thumb_at, near, center + Vector2(thumb * r * 0.3, -r * 0.1)])
		draw_colored_polygon(web, dark)
	for i in fingers.size() - 1:
		draw_line(fingers[i] + Vector2(0, finger_r * 0.2), fingers[i + 1] + Vector2(0, finger_r * 0.2), ink, maxf(r * 0.07, 0.8), true)
	if pocket:
		draw_circle(center + Vector2(0, r * 0.12), r * 0.55, dark)
		draw_circle(center + Vector2(0, r * 0.12), r * 0.42, Color(0.47, 0.28, 0.12))
		var lace_w := maxf(r * 0.08, 0.8)
		for f in fingers:
			var toward := (center - f).normalized()
			draw_line(f - toward * finger_r * 0.6, f + toward * finger_r * 0.4, lace, lace_w, true)
	else:
		# Back of a padded mitt: lighter pad inside a darker rim, with a few laces.
		draw_circle(center + Vector2(0, r * 0.05), r * 0.7, Color(0.64, 0.42, 0.21))
		var lace_w := maxf(r * 0.08, 0.8)
		draw_line(center + Vector2(-r * 0.45, r * 0.2), center + Vector2(r * 0.45, r * 0.2), lace, lace_w, true)
		draw_line(center + Vector2(-r * 0.4, r * 0.48), center + Vector2(r * 0.4, r * 0.48), lace, lace_w, true)
		for i in 5:
			var ang := -PI * 0.5 + (float(i) - 2.0) * 0.36
			var dir := Vector2(cos(ang), sin(ang))
			draw_line(center + dir * r * 0.72, center + dir * r * 0.98, lace, lace_w, true)
	draw_line(center + Vector2(-r * 0.6, r * 0.85), center + Vector2(r * 0.6, r * 0.85), ink, maxf(r * 0.1, 1.0), true)


func _draw_hud() -> void:
	_draw_scoreboard()
	draw_rect(Rect2(16, 12, 348, 126), Color(0.04, 0.05, 0.06, 0.84))
	draw_rect(Rect2(16, 12, 4, 126), GOLD)
	_text(Vector2(32, 40), "CARIBOOZE SIMULATOR", 20, GOLD)
	var shown_outs := half_outs if phase == "opponent" else outs
	_text(Vector2(32, 70), "OUTS", 16, Color(0.9, 0.9, 0.88))
	for i in 3:
		var center := Vector2(94.0 + float(i) * 26.0, 63.0)
		draw_circle(center, 9.0, Color(0.85, 0.16, 0.14) if i < shown_outs else Color(0.2, 0.22, 0.24))
		draw_arc(center, 9.0, 0, TAU, 16, Color(0.7, 0.72, 0.74), 1.5, true)
	_text(Vector2(178, 70), "%d - %d" % [balls, strikes], 16, Color.WHITE)
	var mode_line := "POWER" if power_mode else "CONTACT"
	_text(Vector2(240, 70), mode_line, 16, GOLD if power_mode else Color(0.9, 0.9, 0.88))
	_text(Vector2(318, 70), "H %d" % hits, 16, Color.WHITE)
	if phase == "opponent":
		_text(Vector2(32, 108), "%s  %s" % [THEM_NAME, AWAY_LINEUP[opp_batter_i]], 20, Color.WHITE)
	else:
		_text(Vector2(32, 108), "%d  %s" % [batter_i + 1, lineup[batter_i]], 20, Color.WHITE)
	draw_rect(Rect2(16, 148, 300, 168), Color(0.04, 0.05, 0.06, 0.72))
	for i in lineup.size():
		var on_deck := i == batter_i and phase != "opponent"
		var row_color := GOLD if on_deck else Color(0.78, 0.79, 0.8)
		_text(Vector2(26, 160 + i * 16), "%d  %s" % [i + 1, lineup[i]], 14, row_color)
		var line := _stat_line(i)
		if line != "":
			var wide_s := font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, 13).x
			_text(Vector2(306.0 - wide_s, 160 + i * 16), line, 13, row_color)
	if phase == "ready":
		var title := "%s  vs  %s" % [US_NAME, THEM_NAME]
		var wide_t := font.get_string_size(title, HORIZONTAL_ALIGNMENT_LEFT, -1, 30).x
		draw_rect(Rect2(640.0 - wide_t * 0.5 - 24.0, 158, wide_t + 48.0, 66), Color(0.04, 0.05, 0.06, 0.82))
		_text(Vector2(640, 186), title, 30, GOLD, true)
		_text(Vector2(640, 212), "%d INNINGS    TAP OR SPACE TO SWING" % INNINGS, 14, Color(0.9, 0.9, 0.88), true)
	elif message != "":
		var good := "HOME" in message or "SINGLE" in message or "DOUBLE" in message or "TRIPLE" in message or "WALK" in message
		if phase == "opponent":
			good = false
		var banner := font.get_string_size(message, HORIZONTAL_ALIGNMENT_LEFT, -1, 26)
		draw_rect(Rect2(640.0 - banner.x * 0.5 - 16.0, 158, banner.x + 32.0, 34), Color(0.04, 0.05, 0.06, 0.8))
		_text(Vector2(640, 182), message, 26, GOLD if good else Color.WHITE, true)
	if phase == "windup" or phase == "flight":
		draw_rect(Rect2(430, 698, 420, 18), Color(0.04, 0.05, 0.06, 0.72))
		_text(Vector2(640, 714), "TAP OR SPACE TO SWING", 14, Color(0.92, 0.92, 0.9), true)
	if phase != "windup" and phase != "ready" and phase != "opponent" and not pitch.is_empty():
		var radar := "%d MPH" % pitch_mph
		var label := str(pitch["name"]).to_upper()
		var wide := maxf(font.get_string_size(radar, HORIZONTAL_ALIGNMENT_LEFT, -1, 28).x, font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 14).x)
		draw_rect(Rect2(640.0 - wide * 0.5 - 18.0, 14, wide + 36.0, 58), Color(0.04, 0.05, 0.06, 0.82))
		_text(Vector2(640, 40), radar, 28, Color.WHITE, true)
		_text(Vector2(640, 62), label, 14, GOLD, true)


func _draw_scoreboard() -> void:
	var cols := mini(maxi(inning, INNINGS), 9)
	var first := maxi(inning - cols + 1, 1)
	var cw := 30.0
	var label_w := 112.0
	var extra := 34.0
	var width := label_w + float(cols) * cw + extra * 2.0 + 12.0
	var x := 640.0 - width * 0.5
	var y := 78.0
	draw_rect(Rect2(x, y, width, 74), Color(0.04, 0.1, 0.07, 0.94))
	draw_rect(Rect2(x, y, width, 3), GOLD)
	for i in cols:
		_text(Vector2(x + label_w + float(i) * cw + cw * 0.5, y + 16), str(first + i), 12, Color(0.72, 0.76, 0.7), true)
	_text(Vector2(x + label_w + float(cols) * cw + extra * 0.5, y + 16), "R", 13, GOLD, true)
	_text(Vector2(x + label_w + float(cols) * cw + extra * 1.5, y + 16), "H", 13, GOLD, true)
	_score_row(x, y + 34, US_NAME, true, cols, first, label_w, cw, extra)
	_score_row(x, y + 56, THEM_NAME, false, cols, first, label_w, cw, extra)


func _score_row(x: float, y: float, label: String, ours: bool, cols: int, first: int, label_w: float, cw: float, extra: float) -> void:
	var idle := phase == "gameover" or phase == "final" or phase == "ready"
	var active := (ours and phase != "opponent" and not idle) or (not ours and phase == "opponent")
	_text(Vector2(x + 8, y), label, 14, GOLD if active else Color(0.82, 0.84, 0.8))
	for i in cols:
		_text(Vector2(x + label_w + float(i) * cw + cw * 0.5, y), _inning_cell(first + i, ours), 14, Color.WHITE, true)
	_text(Vector2(x + label_w + float(cols) * cw + extra * 0.5, y), str(_run_total(ours)), 14, GOLD, true)
	_text(Vector2(x + label_w + float(cols) * cw + extra * 1.5, y), str(_hit_total(ours)), 14, Color.WHITE, true)


func _inning_cell(n: int, ours: bool) -> String:
	var line: Array = our_line if ours else their_line
	var idx := n - 1
	if idx < line.size():
		return str(int(line[idx]))
	if n == inning and ours and _our_half_live():
		return str(inning_runs)
	if n == inning and not ours and phase == "opponent":
		return str(half_runs)
	return "·"


func _our_half_live() -> bool:
	return phase != "gameover" and phase != "final" and phase != "ready" and phase != "opponent"


func _run_total(ours: bool) -> int:
	var line: Array = our_line if ours else their_line
	var sum := _tally(line)
	if ours and our_line.size() < inning and _our_half_live():
		sum += inning_runs
	if not ours and phase == "opponent":
		sum += half_runs
	return sum


func _hit_total(ours: bool) -> int:
	if ours:
		if our_line.size() < inning and _our_half_live():
			return our_hits + hits
		return our_hits
	if phase == "opponent":
		return their_hits + half_hits
	return their_hits


func _text(pos: Vector2, text: String, size: int, color: Color, center := false) -> void:
	var p := pos
	if center:
		p.x -= font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x * 0.5
	draw_string(font, p, text, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)