--!strict
--[[
	SquishyProceduralAnimator
	-------------------------------------------------------------------------
	AppalachAI Studios -- procedural "squishy" character animator for Roblox.

	What it's for:
		Brings a single-mesh body to life WITHOUT a skeleton or rig. Point it
		at any MeshPart (or a Model with a PrimaryPart / first MeshPart) and it
		handles the bouncy, jelly-ish, alive-feeling motion for you: a gentle
		idle bob, a snappy squash-and-rebound on hits, a stretch on launches,
		a wobble that settles like jelly, and a happy celebration burst.

		It's a "single_mesh_procedural" animator -- everything is driven by
		tweaking the part's Size (squash/stretch), CFrame (vertical bob), and a
		small rotation (wobble), all relative to a base transform we capture the
		first time we touch a target. Nothing here needs a Humanoid, Animator,
		AnimationController, or any external animation library. Pure Luau.

	Design notes / the polite contract:
		* We capture base Size + base CFrame on first use and animate relative
		  to those. stop() restores exactly what we captured.
		* We never touch CanCollide or Anchored -- that's your call, not ours.
		* Per-target state is keyed by the resolved part, so a whole village of
		  critters can squish independently at the same time.
		* Connections are tracked and cleaned up. Targets that get destroyed
		  are noticed and dropped so we don't leak.

	Usage example:
		local Squishy = require(path.to.SquishyProceduralAnimator)

		-- Keep a little forest buddy idling in the corner of the map:
		Squishy.playIdle(workspace.MossGoblin)

		-- React to a tap / stomp:
		button.MouseButton1Click:Connect(function()
			Squishy.playSquish(workspace.MossGoblin, 0.8)
		end)

		-- Launch it skyward:
		Squishy.playStretch(workspace.MossGoblin, Vector3.yAxis)

		-- Big win -- celebrate with rarity-tinted sparkles:
		Squishy.playCelebrate(workspace.MossGoblin, "legendary")

		-- All done:
		Squishy.stop(workspace.MossGoblin)

	Heads up: this is hand-tuned, lively motion -- not a physics solver and not
	a substitute for a real rig. It's meant to feel warm and characterful, and
	you're encouraged to tweak the profile numbers to taste.

	(c) AppalachAI Studios. Sample IP owner: AppalachAI Studios.
-------------------------------------------------------------------------]]

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local SquishyProceduralAnimator = {}

-- ===========================================================================
-- Types
-- ===========================================================================

export type Target = MeshPart | Model | BasePart

export type IdleProfile = {
	bobHeight: number?, -- studs of vertical travel
	bobSpeed: number?, -- cycles per second
	squashAmount: number?, -- 0..1 how much it flattens at the bottom
	swaySpeed: number?, -- gentle rotational sway cycles per second
	swayAngle: number?, -- degrees of sway
}

type Vibe = "idle" | "squish" | "stretch" | "wobble" | "celebrate" | "none"

type TargetState = {
	part: BasePart,
	model: Model?,
	baseSize: Vector3,
	baseCFrame: CFrame,
	vibe: Vibe,
	clock: number, -- seconds elapsed in the current procedural motion
	heartbeat: RBXScriptConnection?,
	ancestry: RBXScriptConnection?,
	tweens: { Tween },
	idle: IdleProfile?, -- resolved idle profile, when idling
}

-- ===========================================================================
-- Tunables (sensible, hand-tuned defaults -- adjust to taste)
-- ===========================================================================

local DEFAULT_IDLE: IdleProfile = {
	bobHeight = 0.45,
	bobSpeed = 1.1,
	squashAmount = 0.08,
	swaySpeed = 0.55,
	swayAngle = 2.5,
}

-- Rarity tints for the celebrate sparkle. Lowercase keys; we normalize input.
local RARITY_COLORS: { [string]: Color3 } = {
	common = Color3.fromRGB(200, 205, 210),
	uncommon = Color3.fromRGB(110, 205, 120),
	rare = Color3.fromRGB(90, 160, 245),
	epic = Color3.fromRGB(180, 110, 240),
	legendary = Color3.fromRGB(255, 190, 70),
	mythic = Color3.fromRGB(255, 95, 130),
}
local DEFAULT_RARITY_COLOR = RARITY_COLORS.common

-- ===========================================================================
-- Internal state registry (keyed by resolved BasePart)
-- ===========================================================================

local states: { [BasePart]: TargetState } = {}

-- ===========================================================================
-- Small helpers
-- ===========================================================================

local function clamp01(n: number): number
	if n < 0 then
		return 0
	elseif n > 1 then
		return 1
	end
	return n
end

--[[
	Resolve a Target down to the single BasePart we actually animate.
	For a Model we prefer PrimaryPart, then the first MeshPart, then the first
	BasePart. For a BasePart we just use it directly.
]]
local function resolvePart(target: Target): (BasePart?, Model?)
	if typeof(target) ~= "Instance" then
		return nil, nil
	end

	if target:IsA("Model") then
		local model = target :: Model
		if model.PrimaryPart then
			return model.PrimaryPart, model
		end
		-- Prefer a MeshPart if we can find one.
		for _, descendant in model:GetDescendants() do
			if descendant:IsA("MeshPart") then
				return descendant, model
			end
		end
		-- Fall back to any BasePart.
		for _, descendant in model:GetDescendants() do
			if descendant:IsA("BasePart") then
				return descendant :: BasePart, model
			end
		end
		return nil, model
	elseif target:IsA("BasePart") then
		return target :: BasePart, nil
	end

	return nil, nil
end

-- Cancel and clear any TweenService tweens we own for this state.
local function cancelTweens(state: TargetState)
	for _, tween in state.tweens do
		pcall(function()
			tween:Cancel()
		end)
	end
	table.clear(state.tweens)
end

-- Disconnect the Heartbeat driver if it's running.
local function disconnectHeartbeat(state: TargetState)
	if state.heartbeat then
		state.heartbeat:Disconnect()
		state.heartbeat = nil
	end
end

-- Write a relative transform onto the part. scale and angle are optional.
local function applyTransform(state: TargetState, scale: Vector3, offset: CFrame)
	local part = state.part
	part.Size = state.baseSize * scale
	part.CFrame = state.baseCFrame * offset
end

-- Restore exactly the captured base transform.
local function restoreBase(state: TargetState)
	local part = state.part
	part.Size = state.baseSize
	part.CFrame = state.baseCFrame
end

--[[
	Fetch (or lazily create) the state for a target. The first time we see a
	part we capture its base Size + base CFrame and wire an AncestryChanged
	watcher so a destroyed target self-cleans.
]]
local function getOrCreateState(target: Target): TargetState?
	local part, model = resolvePart(target)
	if not part then
		warn("[SquishyProceduralAnimator] No animatable BasePart found on target.")
		return nil
	end

	local existing = states[part]
	if existing then
		-- Keep the model reference fresh in case it changed.
		existing.model = model or existing.model
		return existing
	end

	local state: TargetState = {
		part = part,
		model = model,
		baseSize = part.Size,
		baseCFrame = part.CFrame,
		vibe = "none",
		clock = 0,
		heartbeat = nil,
		ancestry = nil,
		tweens = {},
		idle = nil,
	}

	-- Self-clean when the part leaves the data model.
	state.ancestry = part.AncestryChanged:Connect(function(_, parent)
		if not parent then
			local s = states[part]
			if s then
				disconnectHeartbeat(s)
				cancelTweens(s)
				if s.ancestry then
					s.ancestry:Disconnect()
					s.ancestry = nil
				end
				states[part] = nil
			end
		end
	end)

	states[part] = state
	return state
end

-- Tear down whatever motion is currently running, but keep the state alive.
local function quietMotion(state: TargetState)
	disconnectHeartbeat(state)
	cancelTweens(state)
	state.vibe = "none"
	state.clock = 0
	state.idle = nil
end

-- ===========================================================================
-- Heartbeat driver
-- ===========================================================================

--[[
	Drive a per-frame procedural motion. updater(state, dt) returns true to keep
	going, or false when the motion has run its course (we then restore base).
]]
local function startHeartbeat(state: TargetState, updater: (TargetState, number) -> boolean)
	disconnectHeartbeat(state)
	state.clock = 0
	state.heartbeat = RunService.Heartbeat:Connect(function(dt)
		state.clock += dt
		local keepGoing = updater(state, dt)
		if not keepGoing then
			disconnectHeartbeat(state)
			restoreBase(state)
			state.vibe = "none"
		end
	end)
end

-- ===========================================================================
-- Sparkle emitter (used by playCelebrate)
-- ===========================================================================

--[[
	Spawn a short-lived ParticleEmitter on the part, tinted by rarity, then
	clean it up. Best-effort and decorative -- failures are swallowed so a
	celebration never breaks the caller's flow.
]]
local function emitSparkle(part: BasePart, color: Color3, lifetimeSeconds: number)
	local ok, emitter = pcall(function()
		local e = Instance.new("ParticleEmitter")
		e.Name = "AppalachAI_Sparkle"
		e.Color = ColorSequence.new(color)
		e.LightEmission = 0.6
		e.Size = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 0.15),
			NumberSequenceKeypoint.new(0.4, 0.5),
			NumberSequenceKeypoint.new(1, 0),
		})
		e.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 0.1),
			NumberSequenceKeypoint.new(1, 1),
		})
		e.Lifetime = NumberRange.new(0.5, 0.9)
		e.Rate = 60
		e.Speed = NumberRange.new(3, 6)
		e.SpreadAngle = Vector2.new(180, 180)
		e.Rotation = NumberRange.new(0, 360)
		e.Acceleration = Vector3.new(0, -4, 0)
		e.Parent = part
		return e
	end)

	if not ok or not emitter then
		return
	end

	local e = emitter :: ParticleEmitter
	task.delay(lifetimeSeconds, function()
		e.Enabled = false
		-- Let the last particles fade before removing the emitter.
		task.delay(1, function()
			if e and e.Parent then
				e:Destroy()
			end
		end)
	end)
end

-- ===========================================================================
-- Public API
-- ===========================================================================

--[[
	playIdle -- gentle looping bounce + subtle squash to keep a character feeling
	alive. Loops until you call another motion or stop().

	@param target Target -- MeshPart, BasePart, or Model (PrimaryPart/first MeshPart).
	@param profile IdleProfile? -- optional overrides (bobHeight, bobSpeed,
	       squashAmount, swaySpeed, swayAngle). Missing fields fall back to
	       friendly defaults.
]]
function SquishyProceduralAnimator.playIdle(target: Target, profile: IdleProfile?)
	local state = getOrCreateState(target)
	if not state then
		return
	end

	quietMotion(state)

	local resolved: IdleProfile = {
		bobHeight = (profile and profile.bobHeight) or DEFAULT_IDLE.bobHeight,
		bobSpeed = (profile and profile.bobSpeed) or DEFAULT_IDLE.bobSpeed,
		squashAmount = (profile and profile.squashAmount) or DEFAULT_IDLE.squashAmount,
		swaySpeed = (profile and profile.swaySpeed) or DEFAULT_IDLE.swaySpeed,
		swayAngle = (profile and profile.swayAngle) or DEFAULT_IDLE.swayAngle,
	}
	state.idle = resolved
	state.vibe = "idle"

	local bobHeight = resolved.bobHeight :: number
	local bobSpeed = resolved.bobSpeed :: number
	local squashAmount = resolved.squashAmount :: number
	local swaySpeed = resolved.swaySpeed :: number
	local swayAngle = math.rad(resolved.swayAngle :: number)

	startHeartbeat(state, function(s, _dt)
		local t = s.clock
		local phase = t * bobSpeed * math.pi * 2

		-- Vertical bob: a soft sine. Bottom of the arc squashes a touch.
		local bob = math.sin(phase)
		local height = (bob * 0.5 + 0.5) * bobHeight -- 0..bobHeight, feet never sink below base

		-- Squash is strongest when lowest (bob near -1).
		local squash = squashAmount * (0.5 - 0.5 * bob) -- 0 at top, squashAmount at bottom
		local scale = Vector3.new(
			1 + squash * 0.5,
			1 - squash,
			1 + squash * 0.5
		)

		local sway = math.sin(t * swaySpeed * math.pi * 2) * swayAngle
		local offset = CFrame.new(0, height, 0) * CFrame.Angles(0, 0, sway)

		applyTransform(s, scale, offset)
		return true -- idle loops forever until interrupted
	end)
end

--[[
	playSquish -- fast squash then an elastic rebound. Great for taps, hits, and
	stomps. Runs once, then restores base.

	@param target Target
	@param intensity number -- 0..1. Higher means a deeper squash and bigger
	       rebound. Values outside the range are clamped.
]]
function SquishyProceduralAnimator.playSquish(target: Target, intensity: number?)
	local state = getOrCreateState(target)
	if not state then
		return
	end

	local amount = clamp01(intensity or 0.6)
	quietMotion(state)
	state.vibe = "squish"

	local maxSquash = 0.45 * amount -- how flat it gets at the peak
	local duration = 0.55 -- seconds for the full squash + elastic settle

	startHeartbeat(state, function(s, _dt)
		local t = s.clock
		if t >= duration then
			return false
		end

		local p = t / duration -- 0..1 progress

		-- Quick squash in, then an elastic, decaying rebound out.
		local squash: number
		if p < 0.18 then
			-- Snap down fast.
			squash = maxSquash * (p / 0.18)
		else
			-- Elastic rebound: decaying cosine oscillation back to zero.
			local rp = (p - 0.18) / 0.82
			local decay = math.exp(-5 * rp)
			squash = maxSquash * decay * math.cos(rp * math.pi * 3)
		end

		-- Positive squash = flatten (wider, shorter). Negative = stretch up.
		local scale = Vector3.new(
			1 + squash * 0.6,
			1 - squash,
			1 + squash * 0.6
		)
		-- Keep the base resting on its footprint as it squashes.
		local drop = -(s.baseSize.Y * 0.5) * squash
		local offset = CFrame.new(0, drop, 0)

		applyTransform(s, scale, offset)
		return true
	end)
end

--[[
	playStretch -- elongate along a direction (think jump or launch) then recover.
	Runs once, then restores base.

	@param target Target
	@param direction Vector3? -- world-ish direction to stretch along. Defaults to
	       straight up (Vector3.yAxis). The vector is normalized; its axis with
	       the largest magnitude drives the stretch.
]]
function SquishyProceduralAnimator.playStretch(target: Target, direction: Vector3?)
	local state = getOrCreateState(target)
	if not state then
		return
	end

	quietMotion(state)
	state.vibe = "stretch"

	local dir = direction or Vector3.yAxis
	if dir.Magnitude < 1e-4 then
		dir = Vector3.yAxis
	end
	dir = dir.Unit

	-- Pick the dominant local axis to elongate along; squeeze the other two.
	local ax, ay, az = math.abs(dir.X), math.abs(dir.Y), math.abs(dir.Z)
	local stretchAxis: Vector3
	if ax >= ay and ax >= az then
		stretchAxis = Vector3.new(1, 0, 0)
	elseif ay >= ax and ay >= az then
		stretchAxis = Vector3.new(0, 1, 0)
	else
		stretchAxis = Vector3.new(0, 0, 1)
	end

	local maxStretch = 0.5 -- elongation at the peak
	local duration = 0.7

	startHeartbeat(state, function(s, _dt)
		local t = s.clock
		if t >= duration then
			return false
		end

		local p = t / duration

		-- Anticipation dip, big elongation, then settle.
		local stretch: number
		if p < 0.15 then
			-- Tiny squash to anticipate the launch.
			stretch = -0.15 * maxStretch * (p / 0.15)
		else
			local rp = (p - 0.15) / 0.85
			local decay = math.exp(-3.2 * rp)
			-- One strong elongation that eases back toward base.
			stretch = maxStretch * decay * math.sin(rp * math.pi)
		end

		-- Volume-ish preservation: elongate on the chosen axis, squeeze others.
		local along = 1 + stretch
		local across = 1 - stretch * 0.45
		local scale = Vector3.new(
			stretchAxis.X > 0.5 and along or across,
			stretchAxis.Y > 0.5 and along or across,
			stretchAxis.Z > 0.5 and along or across
		)

		-- If stretching vertically, lift with it so it reads as a launch.
		local lift = 0
		if stretchAxis.Y > 0.5 then
			lift = (s.baseSize.Y * 0.5) * stretch
		end
		local offset = CFrame.new(0, lift, 0)

		applyTransform(s, scale, offset)
		return true
	end)
end

--[[
	playWobble -- damped jelly side-to-side oscillation, the kind you'd want right
	after a big squish. Runs once, then restores base.

	@param target Target
	@param intensity number? -- 0..1. Higher means a wider wobble that takes a
	       little longer to settle. Clamped to range.
]]
function SquishyProceduralAnimator.playWobble(target: Target, intensity: number?)
	local state = getOrCreateState(target)
	if not state then
		return
	end

	local amount = clamp01(intensity or 0.6)
	quietMotion(state)
	state.vibe = "wobble"

	local maxAngle = math.rad(14 * amount) -- peak tilt
	local maxShift = 0.25 * amount -- peak lateral nudge in studs
	local frequency = 5.5 -- oscillations per second
	local duration = 1.1 + amount * 0.5

	startHeartbeat(state, function(s, _dt)
		local t = s.clock
		if t >= duration then
			return false
		end

		local decay = math.exp(-3.0 * (t / duration))
		local osc = math.sin(t * frequency * math.pi * 2)

		local angle = maxAngle * decay * osc
		local shift = maxShift * decay * osc

		-- A touch of jelly squash that breathes with the wobble.
		local squash = 0.06 * amount * decay * math.cos(t * frequency * math.pi * 2)
		local scale = Vector3.new(1 + squash, 1 - squash, 1 + squash)

		local offset = CFrame.new(shift, 0, 0) * CFrame.Angles(0, 0, angle)
		applyTransform(s, scale, offset)
		return true
	end)
end

--[[
	playCelebrate -- an excited multi-bounce, with optional rarity-tinted sparkle
	particles. Runs once, then restores base.

	@param target Target
	@param rarity string? -- one of common/uncommon/rare/epic/legendary/mythic
	       (case-insensitive). Controls sparkle tint and a bit of extra pep.
	       Unknown values fall back to a gentle common tint.
]]
function SquishyProceduralAnimator.playCelebrate(target: Target, rarity: string?)
	local state = getOrCreateState(target)
	if not state then
		return
	end

	quietMotion(state)
	state.vibe = "celebrate"

	local key = string.lower(rarity or "common")
	local color = RARITY_COLORS[key] or DEFAULT_RARITY_COLOR

	-- Rarer = a little more excited (one extra bounce of energy).
	local rarityRank: { [string]: number } = {
		common = 0,
		uncommon = 1,
		rare = 2,
		epic = 3,
		legendary = 4,
		mythic = 5,
	}
	local pep = 1 + (rarityRank[key] or 0) * 0.06

	local bounces = 3
	local hopHeight = 1.1 * pep
	local duration = 1.3

	-- A short celebratory sparkle, tinted by rarity.
	emitSparkle(state.part, color, math.min(0.9, 0.4 + (rarityRank[key] or 0) * 0.1))

	startHeartbeat(state, function(s, _dt)
		local t = s.clock
		if t >= duration then
			return false
		end

		local p = t / duration
		local envelope = math.sin(p * math.pi) -- ease in and out over the whole bit

		-- Multiple excited hops; |sin| gives a bouncy, never-below-floor arc.
		local hop = math.abs(math.sin(p * math.pi * bounces)) * hopHeight * envelope

		-- Anticipation squash at the bottom of each hop, stretch at the top.
		local squash = 0.12 * pep * math.cos(p * math.pi * bounces * 2) * envelope
		local scale = Vector3.new(
			1 + squash * 0.6,
			1 - squash,
			1 + squash * 0.6
		)

		-- A jaunty little sway so it feels giddy, not robotic.
		local sway = math.sin(p * math.pi * bounces) * math.rad(6) * envelope
		local offset = CFrame.new(0, hop, 0) * CFrame.Angles(0, sway, 0)

		applyTransform(s, scale, offset)
		return true
	end)
end

--[[
	stop -- cancel any active motion on a target and restore the exact base
	transform we captured on first use. Safe to call even if nothing is playing.
	Keeps the captured base around so the next call re-uses it.

	@param target Target
]]
function SquishyProceduralAnimator.stop(target: Target)
	local part = resolvePart(target)
	if not part then
		return
	end

	local state = states[part]
	if not state then
		return
	end

	quietMotion(state)
	restoreBase(state)
end

--[[
	forget -- fully release a target: stop motion, restore base, and drop all
	tracked state and connections. Use this when you're done with a character and
	want a clean slate (the next play* call will re-capture a fresh base).

	@param target Target
]]
function SquishyProceduralAnimator.forget(target: Target)
	local part = resolvePart(target)
	if not part then
		return
	end

	local state = states[part]
	if not state then
		return
	end

	quietMotion(state)
	restoreBase(state)
	if state.ancestry then
		state.ancestry:Disconnect()
		state.ancestry = nil
	end
	states[part] = nil
end

return SquishyProceduralAnimator
