-- animesonic (junio) — wallrun logic
-- UNIVERSAL WALL START/TRANSFER + SKY EXCLUSION (midpoint-aware)
-- STRICT MIDPOINT SPAN VALIDATION (no "air runs") + TRUST WINDOWS
-- fixes: side-aware sky checks for two-sided spans, tiny start-time repair scan, smaller EPS

freeslot(
    "S_PLAY_JUNIOWALLRUN1",
    "S_PLAY_JUNIOWALLRUN2",
    "S_PLAY_JUNIOWALLRUN3",
    "sfx_wibble"
)

states[S_PLAY_JUNIOWALLRUN1] = { sprite = SPR_PLAY, frame = 21, tics = 10, nextstate = S_PLAY_JUNIOWALLRUN1 }
states[S_PLAY_JUNIOWALLRUN2] = { sprite = SPR_PLAY, frame = 23, tics = 10, nextstate = S_PLAY_JUNIOWALLRUN2 }
states[S_PLAY_JUNIOWALLRUN3] = { sprite = SPR_PLAY, frame = 24, tics = 10, nextstate = S_PLAY_JUNIOWALLRUN3 }

local wallRunDebug = CV_RegisterVar({ name = "wallRunDebug", defaultvalue = 0, flags = CV_SHOWMODIF|CV_NETVAR, PossibleValue = CV_OnOff })
local wallRunHighlight = CV_RegisterVar({ name = "wallRunHighlight", defaultvalue = 0, flags = CV_SHOWMODIF|CV_NETVAR, PossibleValue = CV_OnOff })

local cv_junio_minhs      = CV_RegisterVar({name="junio_minhs",      defaultvalue=15, flags=CV_SHOWMODIF|CV_NETVAR, PossibleValue={MIN=0, MAX=64}})
local cv_junio_cornerlock = CV_RegisterVar({name="junio_cornerlock", defaultvalue=10, flags=CV_SHOWMODIF|CV_NETVAR, PossibleValue={MIN=0, MAX=20}})
local cv_junio_cornerboost= CV_RegisterVar({name="junio_cornerboost",defaultvalue=10, flags=CV_SHOWMODIF|CV_NETVAR, PossibleValue={MIN=0, MAX=20}})
local cv_junio_pressforce = CV_RegisterVar({name="junio_pressforce", defaultvalue=50, flags=CV_SHOWMODIF|CV_NETVAR, PossibleValue={MIN=0, MAX=80}})


local cv_junio_walldesc_base  = CV_RegisterVar({name="junio_walldesc_base",  defaultvalue=50,  flags=CV_SHOWMODIF|CV_NETVAR, PossibleValue={MIN=0, MAX=2000}})
local cv_junio_walldesc_accel = CV_RegisterVar({name="junio_walldesc_accel", defaultvalue=200, flags=CV_SHOWMODIF|CV_NETVAR, PossibleValue={MIN=0, MAX=3000}})
local cv_junio_walldesc_max   = CV_RegisterVar({name="junio_walldesc_max",   defaultvalue=900, flags=CV_SHOWMODIF|CV_NETVAR, PossibleValue={MIN=0, MAX=3000}})


-- forward declarations for helpers used by earlier functions
local ClosestPointOnLineSegment
local GetBodyRange
local HasClimbableWallSpanAt


local function DBG(pmo, msg)
    if not wallRunDebug.value then return end
    if pmo and pmo.valid and pmo.player then CONS_Printf(pmo.player, msg) end
end

local function CheckHasVertex(vert, line)
    if not vert then return true end
    return (line.v1 == vert) or (line.v2 == vert)
end

-- Corner transfer that prefers the player's intended sidemove direction.
-- Chooses among lines sharing the vertex; uses HasClimbableWallSpanAt from WALLRUNAA.
local function TryCornerTransfer(p, curLine, vx, vy)
    local best, bestDelta = nil, nil

    -- derive intended side direction: 1=right, -1=left, 0=none
    local desiredDir = p.player.wallRunSideDir
    local sideInput  = p.player.cmd.sidemove or 0
    if desiredDir == nil then
        if     sideInput >  6 then desiredDir =  1
        elseif sideInput < -6 then desiredDir = -1
        else                      desiredDir =  0
        end
    end

    -- current intended horizontal angle on the *current* wall
    local curLineAngle = R_PointToAngle2(curLine.v1.x, curLine.v1.y, curLine.v2.x, curLine.v2.y)
    local curRight = curLineAngle
    if P_PointOnLineSide(p.x, p.y, curLine) then curRight = curRight + ANGLE_180 end
    local curLeft  = curRight + ANGLE_180

    local curIntent = curLeft
    if desiredDir ==  1 then curIntent = curRight
    elseif desiredDir == 0 then
        -- pick the one closer to current facing when no sidemove
        local face = (p.player and p.player.drawangle) or p.angle
        local dR = curRight - face; if dR < -ANGLE_180 then dR = dR + ANGLE_MAX end; if dR > ANGLE_180 then dR = dR - ANGLE_MAX end
        local dL = curLeft  - face; if dL < -ANGLE_180 then dL = dL + ANGLE_MAX end; if dL > ANGLE_180 then dL = dL - ANGLE_MAX end
        if (dR < 0) then dR = -dR end
        if (dL < 0) then dL = -dL end
        if dR < dL then curIntent = curRight else curIntent = curLeft end
    end

    local function Probe(_, line)
        if (not line) or (line == curLine) then return end
        -- must share the chosen vertex
        local shares = ((line.v1.x == vx and line.v1.y == vy) or (line.v2.x == vx and line.v2.y == vy))
        if not shares then return end

        -- span must be valid at our body range
        local cx, cy = ClosestPointOnLineSegment(p.x, p.y, line)
        local zmin, zmax = GetBodyRange(p)
        if not HasClimbableWallSpanAt(line, cx, cy, zmin, zmax) then return end

        -- compute candidate’s intended tangent based on desiredDir
        local la = R_PointToAngle2(line.v1.x, line.v1.y, line.v2.x, line.v2.y)
        local right = la
        if P_PointOnLineSide(p.x, p.y, line) then right = right + ANGLE_180 end
        local left  = right + ANGLE_180

        local candIntent
        if     desiredDir ==  1 then candIntent = right
        elseif desiredDir == -1 then candIntent = left
        else
            -- when no sidemove, prefer smaller turn from current intent
            local dR = right - curIntent; if dR < -ANGLE_180 then dR = dR + ANGLE_MAX end; if dR > ANGLE_180 then dR = dR - ANGLE_MAX end
            local dL = left  - curIntent; if dL < -ANGLE_180 then dL = dL + ANGLE_MAX end; if dL > ANGLE_180 then dL = dL - ANGLE_MAX end
            if (dR < 0) then dR = -dR end
            if (dL < 0) then dL = -dL end
            candIntent = (dR < dL) and right or left
        end

        -- score by small turn from current intent
        local d = candIntent - curIntent
        if d < -ANGLE_180 then d = d + ANGLE_MAX end
        if d >  ANGLE_180 then d = d - ANGLE_MAX end
        if (d < 0) then d = -d end

        if (bestDelta == nil) or (d < bestDelta) then
            best, bestDelta = line, d
        end
    end

    local r = 96*FRACUNIT
    searchBlockmap("lines", Probe, p, vx - r, vx + r, vy - r, vy + r)
    return best
end

ClosestPointOnLineSegment = function(x, y, line)
    local epsilon = FRACUNIT
    local interX, interY = P_ClosestPointOnLine(x, y, line)
    local dist1 = R_PointToDist2(interX, interY, line.v1.x, line.v1.y)
    local dist2 = R_PointToDist2(interX, interY, line.v2.x, line.v2.y)
    local dist3 = R_PointToDist2(line.v1.x, line.v1.y, line.v2.x, line.v2.y)
    local distDiff = (dist1 + dist2) - dist3
    if (-epsilon < distDiff) and (distDiff < epsilon) then
        return interX, interY
    elseif (dist1 < dist2) then
        return line.v1.x, line.v1.y
    else
        return line.v2.x, line.v2.y
    end
end

-- slope-aware sector height queries
local function GetFloorAt(sector, x, y)
    if not sector then return nil end
    local z = sector.floorheight
    if sector.f_slope then z = P_GetZAt(sector.f_slope, x, y) end
    return z
end
local function GetCeilAt(sector, x, y)
    if not sector then return nil end
    local z = sector.ceilingheight
    if sector.c_slope then z = P_GetZAt(sector.c_slope, x, y) end
    return z
end

-- flip-safe player body range
GetBodyRange = function(pmo)
    if (pmo.eflags & MFE_VERTICALFLIP) then
        return pmo.z - pmo.height, pmo.z
    else
        return pmo.z, pmo.z + pmo.height
    end
end

-- mid-height of the player's body (flip-safe)
local function GetBodyMidZ(pmo)
    local half = pmo.height/2
    if (pmo.eflags & MFE_VERTICALFLIP) then
        return pmo.z - half
    else
        return pmo.z + half
    end
end

local function IsSky(pic) return pic == "F_SKY1" end

-- solid FOF side span detection
local function HasSolidFOFSpan(sector, x, y, zmid, eps)
    if not sector then return false end
    for fof in sector.ffloors() do
        if (fof.flags & FF_SOLID) and (fof.flags & FF_EXISTS) then
            if (fof.toppic ~= "F_SKY1") and (fof.bottompic ~= "F_SKY1") then
                local bot = fof.bottomheight
                if fof.b_slope then bot = P_GetZAt(fof.b_slope, x, y) end
                local top = fof.topheight
                if fof.t_slope then top = P_GetZAt(fof.t_slope, x, y) end
                if (zmid > bot + eps) and (zmid < top - eps) then
                    return true
                end
            end
        end
    end
    return false
end

-- helper: absolute difference without math.*
local function AbsDiff(a, b)
    if a >= b then return a - b else return b - a end
end

-- helper: is a sector essentially zero-height at (x,y)?
local function SectorHasZeroHeight(sector, x, y)
    if not sector then return false end
    local f = GetFloorAt(sector, x, y); if not f then return false end
    local c = GetCeilAt(sector, x, y);  if not c then return false end
    -- treat <= 16 units as "paper-thin" (sliver/rail sectors)
    local d
    if f >= c then d = f - c else d = c - f end
    return d <= 16*FRACUNIT
end

-- legacy "inside solid at feet z" check used as a last-resort for thin/outdoor walls
local function LegacyPointInWall(sector, x, y, z)
    if not sector then return false end
    local floor = GetFloorAt(sector, x, y); if not floor then return false end
    local ceil  = GetCeilAt(sector, x, y);  if not ceil  then return false end

    -- under real floor / over real ceiling (ignore sky as solid)
    if (z < floor) and (not IsSky(sector.floorpic)) then return true end
    if (z > ceil)  and (not IsSky(sector.ceilingpic)) then return true end

    -- inside a solid FOF column
    for fof in sector.ffloors() do
        if (fof.flags & FF_SOLID) and (fof.flags & FF_EXISTS) then
            local bot = fof.bottomheight
            if fof.b_slope then bot = P_GetZAt(fof.b_slope, x, y) end
            local top = fof.topheight
            if fof.t_slope then top = P_GetZAt(fof.t_slope, x, y) end
            if (z > bot) and (z < top) then return true end
        end
    end
    return false
end

-- helper: are BOTH neighbor ceilings sky (freestanding outdoor wall)?
local function BothCeilingsSky(fs, bs)
    if not fs or not bs then return false end
    return IsSky(fs.ceilingpic) and IsSky(bs.ceilingpic)
end

-- UNIVERSAL climbable-span check (keeps air-run prevention; accepts thin & outdoor walls)
HasClimbableWallSpanAt = function(line, x, y, zmin, zmax)
    if not line then return false end

    local zmid = (zmin + zmax)/2
    local eps  = 2*FRACUNIT       -- edge safety
    local top_eps = FRACUNIT      -- slightly looser top margin for special cases

    local fs = line.frontsector
    local bs = line.backsector


    -- ONE-SIDED: front spans floor..ceiling; midpoint-aware + sky guards
    if not bs then
        if not fs then return false end
        local fz = GetFloorAt(fs, x, y); if not fz then return false end
        local cz = GetCeilAt(fs, x, y);  if not cz then return false end
		
				-- pure sky column: never runnable
		if IsSky(fs.ceilingpic) and IsSky(fs.floorpic) then
			return false
		end


        -- forbid grabbing exact sky edges
        if IsSky(fs.ceilingpic) and (zmid >= cz - eps) then return false end
        if IsSky(fs.floorpic)   and (zmid <= fz + eps) then return false end

        if (zmid > fz + eps) and (zmid < cz - eps) then return true end
        if HasSolidFOFSpan(fs, x, y, zmid, eps) then return true end

        -- selective legacy fallback for very thin one-sided facades
        if SectorHasZeroHeight(fs, x, y) and LegacyPointInWall(fs, x, y, zmin) then
            return true
        end
        return false
    end

    -- TWO-SIDED: compute per-side heights (slope-aware)
    local ff = GetFloorAt(fs, x, y); if not ff then return false end
    local fc = GetCeilAt(fs, x, y);  if not fc then return false end
    local bf = GetFloorAt(bs, x, y); if not bf then return false end
    local bc = GetCeilAt(bs, x, y);  if not bc then return false end

    local fsThin = SectorHasZeroHeight(fs, x, y)
    local bsThin = SectorHasZeroHeight(bs, x, y)

    -- QUICK ACCEPT: either side has a solid FOF column that contains zmid
    if HasSolidFOFSpan(fs, x, y, zmid, eps) then return true end
    if HasSolidFOFSpan(bs, x, y, zmid, eps) then return true end

    -- SPECIAL-CASE A: one side is a paper-thin sliver (typical "barrier" wall).
    -- Treat like a solid vertical face from the lower floor up to the higher floor.
    if fsThin or bsThin then
        local lo = ff; if bf < lo then lo = bf end
        local hi = ff; if bf > hi then hi = bf end
        if (zmid > lo + eps) and (zmid < hi - top_eps) then
            return true
        end
        -- fall through to regular checks if midpoint not inside
    end

	-- FLOOR-STEP SPAN: vertical face is on the side with the higher FLOOR.
	local loF = ff; local hiF = bf; local highOnBack = true
	if ff > bf then hiF = ff; loF = bf; highOnBack = false end

	if (zmid > loF + eps) and (zmid < hiF - eps) then
		if highOnBack then
			-- require a non-sky FLOOR on the higher-floor side
			if (not IsSky(bs.floorpic)) then
				return true
			end
		else
			if (not IsSky(fs.floorpic)) then
				return true
			end
		end
	end

	-- CEILING-STEP SPAN: vertical face is on the side with the LOWER CEILING.
	local loC = fc; local hiC = bc; local lowOnBack = false
	if bc < fc then loC = bc; hiC = fc; lowOnBack = true end

	if (zmid > loC + eps) and (zmid < hiC - eps) then
		if lowOnBack then
			-- require a non-sky CEILING on the lower-ceiling side
			if (not IsSky(bs.ceilingpic)) then
				return true
			end
		else
			if (not IsSky(fs.ceilingpic)) then
				return true
			end
		end
	end

	-- SPECIAL-CASE A: one side is a paper-thin sliver (typical "barrier" wall).
	-- Treat like a solid vertical face from the lower floor up to the higher floor,
	-- but only if this isn't just a skybox seam.
	if fsThin or bsThin then
		-- reject if both ceilings are sky AND both floors are sky (pure sky seam),
		-- unless a real solid FOF column exists at zmid.
		local bothFloorsSky = IsSky(fs.floorpic) and IsSky(bs.floorpic)
		local okThinEnv = (not BothCeilingsSky(fs, bs)) and (not bothFloorsSky)
		if not okThinEnv then
			okThinEnv = HasSolidFOFSpan(fs, x, y, zmid, eps) or HasSolidFOFSpan(bs, x, y, zmid, eps)
		end

		if okThinEnv then
			local lo = ff; if bf < lo then lo = bf end
			local hi = ff; if bf > hi then hi = bf end
			if (zmid > lo + eps) and (zmid < hi - top_eps) then
				return true
			end
			-- fall through to regular checks if midpoint not inside
		end
	end

    return false
end

-- optional visual debug
local function HighlightLine(line, z)
    if not wallRunHighlight.value then return end
    if not line then return end
    local v1x = line.v1.x / FRACUNIT
    local v1y = line.v1.y / FRACUNIT
    local v2x = line.v2.x / FRACUNIT
    local v2y = line.v2.y / FRACUNIT
    for i = 1, 10 do
        local x = ((v1x * i) + (v2x * (10 - i))) / 10
        x = x + P_RandomRange(-16, 16)
        x = x * FRACUNIT
        local y = ((v1y * i) + (v2y * (10 - i))) / 10
        y = y + P_RandomRange(-16, 16)
        y = y * FRACUNIT
        P_SpawnMobj(x, y, z, MT_THOK)
    end
end

-- search: prefer nearest valid vertical wall, relax vertex only when in trust/contact/lock
local function JunioWallRunSearch(p, foundline)
    if not foundline then return end

    local needVertex = not ((p.player and p.player.wallRunLatchTics and p.player.wallRunLatchTics > 0)
                          or (p.player and p.player.wallContactTimer and p.player.wallContactTimer > 0)
                          or (p.player and p.player.wallRunLockTick == leveltime))
    if needVertex and (CheckHasVertex(p.player.closestVertex, foundline) == false) then
        return
    end

    -- skip reselecting the last line for a short cooldown after a corner transfer
    if p.player and p.player.junioCornerCooldownTics and (p.player.junioCornerCooldownTics > 0)
    and p.player.junioForbidLine and (foundline == p.player.junioForbidLine) then
        return
    end

    local targetX = p.x + p.momx
    local targetY = p.y + p.momy
    local closeX, closeY = ClosestPointOnLineSegment(targetX, targetY, foundline)
    local foundLineDist = R_PointToDist2(targetX, targetY, closeX, closeY)
    if (foundLineDist > 128 * FRACUNIT) then return end

    local zmin, zmax = GetBodyRange(p)
    local hasSpan = HasClimbableWallSpanAt(foundline, closeX, closeY, zmin, zmax)

    DBG(p, "JUNIO STARTCHK:candidate side="..(P_PointOnLineSide(p.x, p.y, foundline) and 1 or 0)..
             " onesided="..((foundline.backsector == nil) and 1 or 0)..
             " span="..(hasSpan and 1 or 0)..
             " dist="..foundLineDist..
             " air="..(p.z - p.floorz)..
             " jumpDown="..(p.player and p.player.jumpDown or -1)..
             " v1=("..foundline.v1.x..","..foundline.v1.y..") v2=("..foundline.v2.x..","..foundline.v2.y..")")

    if not hasSpan then return end

    if not p.player.lineDist or (foundLineDist < p.player.lineDist) then
        p.player.lineDist = foundLineDist
        p.player.wallRunLine = foundline
        DBG(p, "JUNIO STARTDECIDE: begin=1 reason="..(p.player.lineDist and "nearerCandidate" or "firstCandidate").." lineDist="..foundLineDist)
    end
end

-- facing helpers (unchanged)
local function IsFrontForDisplay(p)
    local vmo, vangle
    if displayplayer and displayplayer.valid then
        vmo = displayplayer.awayviewmobj or (displayplayer.mo and displayplayer.mo.valid and displayplayer.mo) or nil
    end
    if vmo and vmo.valid then vangle = vmo.angle end
    if not vangle then return true end
    local face = (p.player and p.player.drawangle) or p.angle
    local d = face - vangle
    if d < -ANGLE_180 then d = d + ANGLE_MAX end
    if d >  ANGLE_180 then d = d - ANGLE_MAX end
    return (d > -ANGLE_90) and (d < ANGLE_90)
end

-- 8-way facing resolver for 8-rotation sprites (front=1, back=5)
local function GetFacingRotation15(pmo)
    -- get viewer angle (awayviewmobj for chasecam, else displayplayer.mo)
    local vmo, vangle
    if displayplayer and displayplayer.valid then
        vmo = displayplayer.awayviewmobj or (displayplayer.mo and displayplayer.mo.valid and displayplayer.mo) or nil
    end
    if vmo and vmo.valid then vangle = vmo.angle end
    if not vangle then return 1 end

    -- base facing is drawangle if present, else actor angle
    local face = (pmo.player and pmo.player.drawangle) or pmo.angle

    -- in wall-run states, your code visually rotates the player by -90°
    if (pmo.state == S_PLAY_JUNIOWALLRUN1)
    or (pmo.state == S_PLAY_JUNIOWALLRUN2)
    or (pmo.state == S_PLAY_JUNIOWALLRUN3) then
        face = face - ANGLE_90
    end

    -- viewer-relative delta in (-180°, 180°]
    local d = face - vangle
    if d < -ANGLE_225 then d = d + ANGLE_MAX end
    if d >  ANGLE_225 then d = d - ANGLE_MAX end

    -- octant thresholds centered on front/back with 22.5° offsets
    local O  = ANGLE_45/2          -- 22.5°
    local O2 = O  + ANGLE_45       -- 67.5°
    local O3 = O2 + ANGLE_45       -- 112.5°
    local O4 = O3 + ANGLE_45       -- 157.5°

    -- front
    if (d >= -O) and (d < O) then return 1 end
    -- front-right 3/4
    if (d >=  O) and (d < O2) then return 2 end
    -- right side
    if (d >= O2) and (d < O3) then return 3 end
    -- back-right 3/4
    if (d >= O3) and (d < O4) then return 4 end
    -- back (wraps both tails)
    if (d >= O4) or (d < -O4) then return 5 end
    -- back-left 3/4
    if (d >= -O4) and (d < -O3) then return 6 end
    -- left side
    if (d >= -O3) and (d < -O2) then return 7 end
    -- front-left 3/4 (remaining range: [-O2, -O))
    return 8
end

local function JunioWallJump(p)
    S_StartSound(p, sfx_jump, player)
    p.state = S_PLAY_SPRING
	if p.player then p.player.junioJustExited = true end
    if p.player then p.player.wallRunFallTics = 0 end
    local horizAngle
    if (p.player.wallRunLine) then
        horizAngle = R_PointToAngle2(p.player.wallRunLine.v1.x, p.player.wallRunLine.v1.y, p.player.wallRunLine.v2.x, p.player.wallRunLine.v2.y) + ANGLE_90
        if (not P_PointOnLineSide(p.x, p.y, p.player.wallRunLine)) then horizAngle = horizAngle + ANGLE_180 end
    else
        horizAngle = p.player.drawangle
    end
    p.player.drawangle = horizAngle
    if (p.player.wallRunSpeed > 10 * FRACUNIT) then p.player.wallRunSpeed = 10 * FRACUNIT end
    p.momy = p.momy/10
    p.momx = p.momx/10
    P_SetObjectMomZ(p, p.player.wallRunSpeed - (p.player.wallRunSpeed / 4))
end

local function JunioWallRebuffFall(p, lineToUse)
    local horizAngle = R_PointToAngle2(lineToUse.v1.x, lineToUse.v1.y, lineToUse.v2.x, lineToUse.v2.y) + ANGLE_90
    if (P_PointOnLineSide(p.x, p.y, lineToUse)) then horizAngle = horizAngle + ANGLE_180 end
    P_InstaThrust(p, horizAngle + ANGLE_180, 10 * FRACUNIT)
    P_SetObjectMomZ(p, 10 * FRACUNIT)
    S_StartSoundAtVolume(p, sfx_wibble, 100)
    S_StartSoundAtVolume(p, sfx_boingf, 100)
    p.state = S_PLAY_FALL
    if p.player then p.player.wallRunFallTics = 0 end
	if p.player then p.player.junioJustExited = true end
    p.renderflags = p.renderflags & ~RF_HORIZONTALFLIP
    p.rollangle = 0
    p.lastline = nil
end

local function JunioWallRun(p)
    -- expect p to be player.mo
    p.player.lineDist = nil
    p.player.lastWallRunLine = p.player.wallRunLine
    local attemptingStart = p.player.junioAttemptStart

    if not p.player.lastWallRunLine then
        return
    end

    -- nearest vertex for transfers
    if (R_PointToDist2(p.player.lastWallRunLine.v1.x, p.player.lastWallRunLine.v1.y, p.x, p.y)
        < R_PointToDist2(p.player.lastWallRunLine.v2.x, p.player.lastWallRunLine.v2.y, p.x, p.y)) then
        p.player.closestVertex = p.player.lastWallRunLine.v1
    else
        p.player.closestVertex = p.player.lastWallRunLine.v2
    end

    -- reacquire candidate this tic (trust/lock behavior preserved)
    p.player.wallRunLine = nil
    p.player.lineDist = nil
    local sameTick = (p.player.wallRunLockTick == leveltime)

    if sameTick then
        p.player.wallRunLine = p.player.lastWallRunLine
        p.player.lineDist = FRACUNIT
    elseif (p.player.wallRunLatchTics and p.player.wallRunLatchTics > 0) then
        local cx, cy = ClosestPointOnLineSegment(p.x, p.y, p.player.lastWallRunLine)
        local zmin, zmax = GetBodyRange(p)
        if HasClimbableWallSpanAt(p.player.lastWallRunLine, cx, cy, zmin, zmax) then
            p.player.wallRunLine = p.player.lastWallRunLine
            p.player.lineDist = R_PointToDist2(p.x, p.y, cx, cy)
        end
    else
        local searchRadius = 512 * FRACUNIT
        searchBlockmap("lines", JunioWallRunSearch, p,
            p.x - searchRadius, p.x + searchRadius,
            p.y - searchRadius, p.y + searchRadius)
        if (not p.player.wallRunLine) and p.lastline
        and (p.player.wallContactTimer and p.player.wallContactTimer > 0) then
            local cx, cy = ClosestPointOnLineSegment(p.x, p.y, p.lastline)
            local zmin, zmax = GetBodyRange(p)
            if HasClimbableWallSpanAt(p.lastline, cx, cy, zmin, zmax) then
                p.player.wallRunLine = p.lastline
                p.player.lineDist = R_PointToDist2(p.x, p.y, cx, cy)
                DBG(p, "JUNIO TRANSFER: contact+midspan")
            end
        end
    end

    local inTrust = (p.player.wallRunLockTick == leveltime)
                 or (p.player.wallRunLatchTics and p.player.wallRunLatchTics > 0)
                 or (p.player.wallContactTimer and p.player.wallContactTimer > 0)

    ----------------------------------------------------------------
    -- ACTIVE corner transfer using held direction (prevents detaches).
    -- If no candidate yet and we are at the endpoint of the last wall,
    -- pick a neighbor that matches the *player's sidemove intent*.
    ----------------------------------------------------------------
    if (p.player.wallRunLine == nil) and p.player.lastWallRunLine then
        local l = p.player.lastWallRunLine
        local lcX, lcY = ClosestPointOnLineSegment(p.x, p.y, l)
        local dv1 = R_PointToDist2(lcX, lcY, l.v1.x, l.v1.y)
        local dv2 = R_PointToDist2(lcX, lcY, l.v2.x, l.v2.y)
        local NEAR_VERTEX = 16*FRACUNIT  -- slightly looser so high speed doesn’t miss

        if (dv1 <= NEAR_VERTEX) or (dv2 <= NEAR_VERTEX) then
            local vx, vy = l.v2.x, l.v2.y
            if (dv1 <= dv2) then vx, vy = l.v1.x, l.v1.y end

            local nextLine = TryCornerTransfer(p, l, vx, vy)
            if nextLine then
                p.player.wallRunLine = nextLine
                local cx, cy = ClosestPointOnLineSegment(p.x, p.y, nextLine)
                p.player.lineDist = R_PointToDist2(p.x, p.y, cx, cy)

                -- Lock strongly to the new wall to avoid ping-pong & detach.
                p.player.wallContactTimer = 6
                p.player.wallRunLockTick  = leveltime
                p.player.junioAtEndOfWall = false
				-- old:
				-- p.player.wallRunLatchTics = 10
				-- p.player.junioCornerBoostTics = 10
				p.player.wallRunLatchTics     = (cv_junio_cornerlock  and cv_junio_cornerlock.value   or 10)
				p.player.junioCornerBoostTics = (cv_junio_cornerboost and cv_junio_cornerboost.value  or 10)

                p.player.junioCornerCooldownTics = 10
                p.player.junioForbidLine        = l

                DBG(p, "JUNIO CORNER: transfer→intent")
            else
                -- TRUE end-of-wall (no valid neighbor)
                p.player.junioAtEndOfWall = true
                DBG(p, "JUNIO CORNER: end-of-wall (no neighbor)")
            end
        end
    end

    ----------------------------------------------------------------
    -- Detach only on Jump release OR confirmed end-of-wall.
    -- Otherwise stick to previous wall within trust window.
    ----------------------------------------------------------------
    if (p.player.wallRunLine == nil) or (p.player.lineDist == nil) or (p.player.jumpDown == 0) then
        if (p.player.jumpDown == 0) or (p.player.junioAtEndOfWall == true) then
            p.player.wallRunAngleOffset = 0
            p.player.lastX = nil
            if attemptingStart and (p.player.jumpDown > 0) and ((p.player.wallRunLine == nil) or (p.player.lineDist == nil)) then
                DBG(p, "JUNIO STARTDECIDE: begin=0 reason=startFail→EXIT")
                p.state = S_PLAY_FALL
                if p.player then p.player.wallRunFallTics = 0 end
            else
                DBG(p, "JUNIO STARTDECIDE: begin=0 reason=exit")
                JunioWallJump(p)
            end
            p.renderflags = p.renderflags & ~RF_HORIZONTALFLIP
            p.rollangle = 0
            p.player.wallRunSideDir = nil
            p.player.junioAttemptStart = nil
            p.player.junioAtEndOfWall = false
            return
        end

        if inTrust and (p.player.jumpDown > 0) then
            local keep = p.player.lastWallRunLine or p.lastline or p.player.wallRunLine
            if keep then
                local cx, cy = ClosestPointOnLineSegment(p.x, p.y, keep)
                local zmin, zmax = GetBodyRange(p)
                if HasClimbableWallSpanAt(keep, cx, cy, zmin, zmax) then
                    p.player.wallRunLine = keep
                    p.player.lineDist = p.player.lineDist or FRACUNIT
                    return
                end
            end
        end

        -- normal detach if we reach here
        p.player.wallRunAngleOffset = 0
        p.player.lastX = nil
        DBG(p, "JUNIO STARTDECIDE: begin=0 reason=midRunDetach→EXIT")
        JunioWallJump(p)
        p.renderflags = p.renderflags & ~RF_HORIZONTALFLIP
        p.rollangle = 0
        p.player.wallRunSideDir = nil
        p.player.junioAttemptStart = nil
        p.player.junioAtEndOfWall = false
        return
    end

    -- Have a current line
    p.player.junioAtEndOfWall = false

    -- === movement / angles / tilt ===
    local closeX, closeY = ClosestPointOnLineSegment(p.x, p.y, p.player.wallRunLine)

    local lastHorizAngle
    if (p.player.wallRunLine ~= p.player.lastWallRunLine) then
        lastHorizAngle = R_PointToAngle2(
            p.player.lastWallRunLine.v1.x, p.player.lastWallRunLine.v1.y,
            p.player.lastWallRunLine.v2.x, p.player.lastWallRunLine.v2.y
        ) + ANGLE_90
        if (P_PointOnLineSide(p.x, p.y, p.player.lastWallRunLine)) then
            lastHorizAngle = lastHorizAngle + ANGLE_180
        end
    end

    local horizAngle = R_PointToAngle2(
        p.player.wallRunLine.v1.x, p.player.wallRunLine.v1.y,
        p.player.wallRunLine.v2.x, p.player.wallRunLine.v2.y
    ) + ANGLE_90
    if (P_PointOnLineSide(p.x, p.y, p.player.wallRunLine)) then
        horizAngle = horizAngle + ANGLE_180
    end

    if (p.player.wallRunLine ~= p.player.lastWallRunLine) then
        p.player.wallRunAngleOffset = p.player.wallRunAngleOffset + (horizAngle - (lastHorizAngle or 0))
    end

    if (p.player.wallRunAngleOffset ~= 0) and not (p.player.pflags & PF_ANALOGMODE) then
        local delta = p.player.wallRunAngleOffset / 10
        if (delta > ANG1) then delta = ANG1
        elseif (delta < -ANG1) then delta = -ANG1 end
        p.player.wallRunAngleOffset = p.player.wallRunAngleOffset - delta
        p.angle = p.angle + delta
    end

    -- movement inputs
    local MAX_FORWARD       = 128
    local MAX_SIDE          = 128
    local DEADZONE          = 1
    local SIDE_DEADZONE     = 6
    local VERT_SCALE_NUM    = 2
    local MOMZ_CAP          = 20 * FRACUNIT
    local SIDE_SCALE_NUM    = 1
    local SIDE_SCALE_DEN    = 1
    local USE_SIDEMOVE      = true
    -- old: local MIN_HSPEED = 15 * FRACUNIT
	local MIN_HSPEED = (cv_junio_minhs and cv_junio_minhs.value or 15) * FRACUNIT

    local CORNER_BOOST_TICS = 10

    local forwardInput = p.player.cmd.forwardmove or 0
    if forwardInput >  MAX_FORWARD then forwardInput =  MAX_FORWARD end
    if forwardInput < -MAX_FORWARD then forwardInput = -MAX_FORWARD end
    local absF = forwardInput; if absF < 0 then absF = -absF end
    if absF <= DEADZONE then forwardInput = 0; absF = 0 end
    local intendedMomZ = (forwardInput * p.player.wallRunSpeed * VERT_SCALE_NUM) / MAX_FORWARD
    if intendedMomZ >  MOMZ_CAP then intendedMomZ =  MOMZ_CAP end
    if intendedMomZ < -MOMZ_CAP then intendedMomZ = -MOMZ_CAP end

    -- compute tangents (right/left) for along-wall
    local lineAngle = R_PointToAngle2(
        p.player.wallRunLine.v1.x, p.player.wallRunLine.v1.y,
        p.player.wallRunLine.v2.x, p.player.wallRunLine.v2.y
    )
    local tangentRight = lineAngle
    if P_PointOnLineSide(p.x, p.y, p.player.wallRunLine) then
        tangentRight = tangentRight + ANGLE_180
    end
    local tangentLeft = tangentRight + ANGLE_180

    local horizontalAngle = tangentLeft
    local horizontalSpeed = 0

    if USE_SIDEMOVE then
        local sideInput = p.player.cmd.sidemove or 0
        if sideInput >  MAX_SIDE then sideInput =  MAX_SIDE end
        if sideInput < -MAX_SIDE then sideInput = -MAX_SIDE end
        local absS = sideInput; if absS < 0 then absS = -absS end

        if absS > SIDE_DEADZONE then
            local computedSideSpeed = (absS * p.player.wallRunSpeed * SIDE_SCALE_NUM) / (MAX_SIDE * SIDE_SCALE_DEN)
            if computedSideSpeed < 0 then computedSideSpeed = 0 end
            if absF > 0 then
                computedSideSpeed = (computedSideSpeed * (MAX_FORWARD - absF)) / MAX_FORWARD
            end

            if computedSideSpeed < MIN_HSPEED then
                computedSideSpeed = MIN_HSPEED
            end

            if sideInput > 0 then
                horizontalAngle = tangentRight
                p.player.wallRunSideDir = 1
            else
                horizontalAngle = tangentLeft
                p.player.wallRunSideDir = -1
            end
            horizontalSpeed = computedSideSpeed
        end
    end

    -- carry-through at corner even when stick briefly neutral
    if (horizontalSpeed == 0)
    and p.player.junioCornerBoostTics and (p.player.junioCornerBoostTics > 0) then
        if p.player.wallRunSideDir == 1 then
            horizontalAngle = tangentRight
        elseif p.player.wallRunSideDir == -1 then
            horizontalAngle = tangentLeft
        else
            horizontalAngle = tangentLeft
        end
        horizontalSpeed = MIN_HSPEED
    end

    -- facing/tilt (unchanged)
    local sideInput2 = p.player.cmd.sidemove or 0
    local absS2 = sideInput2; if absS2 < 0 then absS2 = -absS2 end
    local SIDE_DEADZONE2 = 5
    local rot15 = GetFacingRotation15(p)
    local ROLL60 = ANGLE_45 + ANG20 + ANG10
    local ROLL50 = ANGLE_45 + ANG2 + ANG2 + ANG1

    if (horizontalSpeed > FRACUNIT*14) and (absS2 > SIDE_DEADZONE2) then
        p.player.drawangle = horizontalAngle
        if sideInput2 < 0 then
            if     rot15 == 1 then p.rollangle = -ROLL60
            elseif rot15 == 5 then p.rollangle =  ROLL60
            elseif rot15 == 2 then p.rollangle = -ROLL50
            elseif rot15 == 8 then p.rollangle = -ROLL50
            elseif rot15 == 4 then p.rollangle =  ROLL50
            elseif rot15 == 6 then p.rollangle =  ROLL50
            else                    p.rollangle =  0
            end
        else
            if     rot15 == 1 then p.rollangle = -ROLL60
            elseif rot15 == 5 then p.rollangle =  ROLL60
            elseif rot15 == 2 then p.rollangle = -ROLL50
            elseif rot15 == 8 then p.rollangle = -ROLL50
            elseif rot15 == 4 then p.rollangle =  ROLL50
            elseif rot15 == 6 then p.rollangle =  ROLL50
            else                    p.rollangle =  0
            end
        end
    else
        p.player.drawangle = horizAngle
        p.rollangle = 0
    end

    -- vertical + along-wall thrust
    -- Apply a fatigue-style descent even while moving: the longer you stay on the wall,
    -- the stronger the downward pull becomes. This gradually cancels upward intent.
    -- Cvars are in "map units per second"; accel is units/sec gained per second.
    if not p.player.wallRunFallTics then p.player.wallRunFallTics = 0 end
    p.player.wallRunFallTics = $ + 1

    local basePerSec  = (cv_junio_walldesc_base  and cv_junio_walldesc_base.value  or 50)
    local accelPerSec = (cv_junio_walldesc_accel and cv_junio_walldesc_accel.value or 325)
    local maxPerSec   = (cv_junio_walldesc_max   and cv_junio_walldesc_max.value   or 900)

    local fallPerSec = basePerSec + ((p.player.wallRunFallTics * accelPerSec) / TICRATE)
    if fallPerSec > maxPerSec then fallPerSec = maxPerSec end
    local fallPerTic = (fallPerSec * FRACUNIT) / TICRATE

    local finalMomZ = intendedMomZ
    if (p.eflags & MFE_VERTICALFLIP) then
        finalMomZ = finalMomZ + fallPerTic
    else
        finalMomZ = finalMomZ - fallPerTic
    end
    P_SetObjectMomZ(p, finalMomZ)


    -- Along-wall thrust (held direction)
    P_InstaThrust(p, horizontalAngle, horizontalSpeed * 4/3)

    -- NEW: press-into-wall force uses the vector to the closest point,
    -- so opposite-sided linedefs won’t push you away at corners.
    local pressAngle = R_PointToAngle2(p.x, p.y, closeX, closeY)
    -- old: P_Thrust(p, pressAngle, 50 * FRACUNIT)
	P_Thrust(p, pressAngle, (cv_junio_pressforce and cv_junio_pressforce.value or 50) * FRACUNIT)

    p.state = S_PLAY_JUNIOWALLRUN1

	if p.player then p.player.junioJustExited = false end

    p.player.junioAttemptStart = nil
    P_SlideMove(p)

    -- timers
    if p.player.wallRunLatchTics and p.player.wallRunLatchTics > 0 then p.player.wallRunLatchTics = p.player.wallRunLatchTics - 1 end
    if p.player.wallContactTimer and p.player.wallContactTimer > 0 then p.player.wallContactTimer = p.player.wallContactTimer - 1 end
    if p.player.junioCornerBoostTics and (p.player.junioCornerBoostTics > 0) then p.player.junioCornerBoostTics = p.player.junioCornerBoostTics - 1 end
    if p.player.junioCornerCooldownTics and (p.player.junioCornerCooldownTics > 0) then
        p.player.junioCornerCooldownTics = p.player.junioCornerCooldownTics - 1
        if p.player.junioCornerCooldownTics == 0 then
            p.player.junioForbidLine = nil
        end
    end

    p.player.lastX, p.player.lastY, p.player.lastZ = p.x, p.y, p.z
end

-- tiny helper: scan a small radius for the nearest valid span line when a start would fail
local function FindNearestValidSpanLine(p, radius)
    local bestLine, bestDist = nil, nil
    local function Probe(_, line)
        local cx, cy = ClosestPointOnLineSegment(p.x, p.y, line)
        local zmin, zmax = GetBodyRange(p)
        if HasClimbableWallSpanAt(line, cx, cy, zmin, zmax) then
            local d = R_PointToDist2(p.x, p.y, cx, cy)
            if (bestDist == nil) or (d < bestDist) then
                bestLine, bestDist = line, d
            end
        end
    end
    -- FIX: anchor must be an MOBJ_T* (the player mobj), not nil
    searchBlockmap("lines", Probe, p, p.x - radius, p.x + radius, p.y - radius, p.y + radius)
    return bestLine
end

addHook("MapLoad", function()
    for player in players.iterate do
        if not player.mo or player.mo.skin ~= "sonic" then continue end
        player.jumpDown, player.wallJumpTime, player.wallJumpLine = 0, 0, 0
        player.wallRunSpeed, player.wallRunAngleOffset = 0, 0
        player.wallRunLine, player.lineDist = nil, nil
        player.lastWallRunLine, player.closestVertex = nil, nil
        player.lastX, player.lastY, player.lastZ = nil, nil, nil
        player.wallRunLatchTics, player.wallContactTimer, player.wallRunLockTick = 0, 0, -1
        player.junioAtEndOfWall = false
        player.wallRunFallTics = 0
        -- ADD:
        player.junioJustExited = false
    end
end)

addHook("PlayerThink", function(player)
    if not player.mo or player.mo.skin ~= "sonic" then return end
	
	    -- ADD: touching ground clears the “just exited” window
    if P_IsObjectOnGround(player.mo) then
        player.junioJustExited = false
    end

    if (player.jumpDown == nil) then
        player.jumpDown, player.wallJumpTime, player.wallJumpLine = 0, 0, 0
        player.wallRunSpeed, player.wallRunAngleOffset = 0, 0
        player.wallRunLine, player.lineDist = nil, nil
        player.lastWallRunLine, player.closestVertex = nil, nil
        player.lastX, player.lastY, player.lastZ = nil, nil, nil
        player.wallRunLatchTics, player.wallContactTimer, player.wallRunLockTick = 0, 0, -1
		player.junioAtEndOfWall = false
		player.junioJustExited = false
		player.wallRunFallTics = 0
    end

    if (player.cmd.buttons & BT_JUMP) then player.jumpDown = player.jumpDown + 1 else player.jumpDown = 0 end
    if (player.wallJumpTime and player.wallJumpTime > 0) then player.wallJumpTime = player.wallJumpTime - 1 end

    if (player.mo.state == S_PLAY_JUNIOWALLRUN1) or (player.mo.state == S_PLAY_JUNIOWALLRUN2) or (player.mo.state == S_PLAY_JUNIOWALLRUN3) then
        JunioWallRun(player.mo)
        if (leveltime % 5 == 0) then
			if (player.mo.eflags & (MFE_TOUCHWATER) or player.mo.eflags & (MFE_UNDERWATER)) // overrides fire version
				local splish = P_SpawnMobjFromMobj(player.mo, 0,0,0, MT_SPLISH)
				splish.momx, splish.momy, splish.momz = P_RandomRange(0,4)*player.mo.scale, P_RandomRange(0,4)*player.mo.scale, -player.mo.momz/2
				splish.flags = $|MF_NOCLIPHEIGHT
				S_StartSound(player.mo, sfx_wslap)
			else
				local dust = P_SpawnMobjFromMobj(player.mo, 0,0,0, MT_SPINDUST)
				dust.momx, dust.momy, dust.momz = P_RandomRange(0,4)*player.mo.scale, P_RandomRange(0,4)*player.mo.scale, -player.mo.momz/2
				dust.flags = $|MF_NOCLIPHEIGHT
				S_StartSound(player.mo, sfx_s3k7e)
			end
		end
    else
        if (player.mo.renderflags & RF_HORIZONTALFLIP) then player.mo.renderflags = player.mo.renderflags & ~RF_HORIZONTALFLIP end
        player.wallRunFallTics = 0
    end
end, false)

addHook("MobjLineCollide", function(p, line)
    if (p.player == nil) or (p.skin ~= "sonic") then return end
    if p.lastline then
        local lx, ly = ClosestPointOnLineSegment(p.x, p.y, p.lastline)
        local dl = R_PointToDist2(p.x, p.y, lx, ly)
        local cx, cy = ClosestPointOnLineSegment(p.x, p.y, line)
        local d  = R_PointToDist2(p.x, p.y, cx, cy)
        if d > dl then return end
    end
    p.lastline = line
    p.player.wallContactTimer = 3
end, MT_PLAYER)

-- engine-block start: trust engine + lock/latch; with pre-start span check and repair scan
addHook("MobjMoveBlocked", function(p)
    if (p.player == nil) or (p.skin ~= "sonic") then return end

    if p.lastline
    and (p.state ~= S_PLAY_JUNIOWALLRUN1) and (p.state ~= S_PLAY_JUNIOWALLRUN2) and (p.state ~= S_PLAY_JUNIOWALLRUN3)
    and ((p.z - p.floorz) > 32 * FRACUNIT) then

        -- check span on the engine-reported line
        local cx, cy = ClosestPointOnLineSegment(p.x, p.y, p.lastline)
        local zmin, zmax = GetBodyRange(p)
        local ok = HasClimbableWallSpanAt(p.lastline, cx, cy, zmin, zmax)

        if not ok then
            -- tiny repair: grab the nearest valid span line so starts don't die
            -- was: 64*FRACUNIT
            local repair = FindNearestValidSpanLine(p, 192*FRACUNIT)
            if repair then
                p.lastline = repair
                cx, cy = ClosestPointOnLineSegment(p.x, p.y, p.lastline)
                ok = HasClimbableWallSpanAt(p.lastline, cx, cy, zmin, zmax)
                DBG(p, "JUNIO STARTSPAN: repair line")
                if not ok and wallRunDebug.value then
                    local fs, bs = p.lastline.frontsector, p.lastline.backsector
                    local ff = fs and GetFloorAt(fs, cx, cy) or 0
                    local fc = fs and GetCeilAt(fs, cx, cy)  or 0
                    local bf = bs and GetFloorAt(bs, cx, cy) or 0
                    local bc = bs and GetCeilAt(bs, cx, cy)  or 0
                    DBG(p, "JUNIO SPANDETAIL: ff="..ff.." bf="..bf.." fc="..fc.." bc="..bc..
                             " zmin="..zmin.." zmax="..zmax)
                end
            end
        end

        DBG(p, "JUNIO STARTSPAN: ok="..(ok and 1 or 0).." zmid="..((zmin+zmax)/2)..
                 " v1=("..p.lastline.v1.x..","..p.lastline.v1.y..") v2=("..p.lastline.v2.x..","..p.lastline.v2.y..")")

        if (ok and p.player.jumpDown > 0) then
            p.player.wallRunSpeed = R_PointToDist2(0, 0, p.player.speed or 0, p.momz / 10)
            if (p.player.wallRunSpeed < 20 * FRACUNIT) then p.player.wallRunSpeed = 20 * FRACUNIT end

            p.player.wallRunLine = p.lastline
            p.player.junioAttemptStart = true

            p.player.wallRunLatchTics = 4
            p.player.wallContactTimer = 3
            p.player.wallRunLockTick = leveltime

            DBG(p, "JUNIO STARTTRY: engineBlock→LOCK+LATCH")
            JunioWallRun(p)
            -- FALLBACK: if the lock+latch call didn't land us in a wallrun state,
            -- but we already proved 'ok', forcibly start to avoid false non-starts.
            if (p.state ~= S_PLAY_JUNIOWALLRUN1) and (p.state ~= S_PLAY_JUNIOWALLRUN2) and (p.state ~= S_PLAY_JUNIOWALLRUN3) then
                p.player.wallRunLine = p.lastline
                if (not p.player.wallRunLatchTics) or (p.player.wallRunLatchTics < 6) then p.player.wallRunLatchTics = 6 end
                p.player.wallContactTimer = 3
                p.player.wallRunLockTick = leveltime
                p.state = S_PLAY_JUNIOWALLRUN1
            end
            p.player.junioAttemptStart = nil
        end
    end
end, MT_PLAYER)
