-- Imports local abs = math.abs local acos = math.acos local asin = math.asin local cos = math.cos local deg = math.deg local max = math.max local min = math.min local sin = math.sin local sqrt = math.sqrt local floor = math.floor local huge = math.huge local random = math.random local tsort = table.sort local tinsert = table.insert local time_global = time_global local normalize = normalize local clamp = clamp local id = 69000 local idOffset = 0 local gizmos = { sphereStart = nil, sphereEnd = nil, rayLines = {}, pathLines = {}, climbNormal = nil, sphereInter = nil, sphereActor = nil, collisionLine = nil } local savedClimbPos local savedClimbNormal local savedInterPos local savedCollisionPos local savedActorPos local savedActorDir local savedJumpPressedTime = huge local savedCamY local savedSpeed = vector():set(0,0,0) local savedSpeedPos = vector():set(0,0,0) local climbActive = false hardcoreModeProperties = { maxClimbWeight = 90, maxClimbHealth = 0.6, } helmets = {} -- Settings local debugMode = false local drf = demonized_randomizing_functions local dgr = demonized_geometry_ray -- Postpone on next n tick local nextTick = _G.nextTick or function(f, n) n = floor(max(n or 1, 1)) AddUniqueCall(function() if n == 1 then return f() else n = n - 1 return false end end) end -- Linear inter/extrapolation local function lerp(a, b, f) return a + f * (b - a) end local function vecLerp(v1, v2, f) return vector():set( lerp(v1.x, v2.x, f), lerp(v1.y, v2.y, f), lerp(v1.z, v2.z, f) ) end local function similar(a, b, eps) eps = eps or 0.00001 return abs(a - b) < eps end local function vecSimilar(v1, v2, eps) return similar(v1.x, v2.x, eps) and similar(v1.y, v2.y, eps) and similar(v1.z, v2.z, eps) end local function cubicBezier(v1, v2, v3, v4, t) return vector():set( (1-t)^3*v1.x + 3*(1-t)^2*t*v2.x + 3*(1-t)*t^2*v3.x + t^3*v4.x, (1-t)^3*v1.y + 3*(1-t)^2*t*v2.y + 3*(1-t)*t^2*v3.y + t^3*v4.y, (1-t)^3*v1.z + 3*(1-t)^2*t*v2.z + 3*(1-t)*t^2*v3.z + t^3*v4.z ) end local function cubicBezier2D(v1, v2, v3, v4, t) return vector():set( (1-t)^3*v1.x + 3*(1-t)^2*t*v2.x + 3*(1-t)*t^2*v3.x + t^3*v4.x, (1-t)^3*v1.y + 3*(1-t)^2*t*v2.y + 3*(1-t)*t^2*v3.y + t^3*v4.y, 0 ) end local function mapRangeClamped(inVal, inMin, inMax, outMin, outMax) local k = min(1, normalize(inVal, inMin, inMax)) return lerp(outMin, outMax, k) end local function array_keys(t, sorted, sort_func) local res = {} local res_count = 1 for k, v in pairs(t) do res[res_count] = k res_count = res_count + 1 end if sorted then if sort_func then tsort(res, sort_func) else tsort(res) end end return res end local function bisect_left(a, x, lo, hi) local lo = lo or 1 local hi = hi or #a if lo < 0 then printf('bisect, lo must be non-negative') return end while lo < hi do local mid = floor((lo + hi) * 0.5) if a[mid] < x then lo = mid+1 else hi = mid end end return lo end local function lookup(t, key, tkeys) if is_empty(t) then return 0 end tkeys = tkeys or array_keys(t, true) local tkeys_len = #tkeys if #tkeys == 1 then return tkeys[1], tkeys[1], tkeys[1] end if key <= tkeys[1] then return tkeys[1], tkeys[1], tkeys[2] end if key >= tkeys[tkeys_len] then return tkeys[tkeys_len], tkeys[tkeys_len - 1], tkeys[tkeys_len] end local where = bisect_left(tkeys, key) local lo = tkeys[where-1] or tkeys[where] local hi = tkeys[where] if lo == hi then return lo, lo, lo end local delta = (key - lo) / (hi - lo) return delta, lo, hi end local function randomFromArray(arr) return arr[random(#arr)] end local function randomPlusMinus(v) return random_float(-v, v) end -- MCM function load_defaults() local t = {} local op = demonized_ledge_grabbing_mcm.op for i, v in ipairs(op.gr) do if v.def ~= nil then t[v.id] = v.def end end return t end settings = load_defaults() function load_settings() settings = load_defaults() if ui_mcm then for k, v in pairs(settings) do settings[k] = ui_mcm.get("demonized_ledge_grabbing/" .. k) end end return settings end function speedSaver() local pos = db.actor:position() local speed = vector():set(pos):sub(savedSpeedPos):mul(1 / device().time_delta) savedSpeedPos = pos savedSpeed = speed end local gizmosInitialized = false function initializeGizmos() if gizmosInitialized then return end gizmosInitialized = true gizmos.sphereStart = debug_render.add_object(id + idOffset, DBG_ScriptObject.sphere):cast_dbg_sphere() idOffset = idOffset + 1 gizmos.sphereEnd = debug_render.add_object(id + idOffset, DBG_ScriptObject.sphere):cast_dbg_sphere() idOffset = idOffset + 1 gizmos.climbNormal = debug_render.add_object(id + idOffset, DBG_ScriptObject.line):cast_dbg_line() idOffset = idOffset + 1 gizmos.collisionLine = debug_render.add_object(id + idOffset, DBG_ScriptObject.line):cast_dbg_line() idOffset = idOffset + 1 gizmos.sphereInter = debug_render.add_object(id + idOffset, DBG_ScriptObject.sphere):cast_dbg_sphere() idOffset = idOffset + 1 gizmos.sphereActor = debug_render.add_object(id + idOffset, DBG_ScriptObject.sphere):cast_dbg_sphere() idOffset = idOffset + 1 for i = 1, 20 do gizmos.pathLines[i] = debug_render.add_object(id + idOffset, DBG_ScriptObject.line):cast_dbg_line() idOffset = idOffset + 1 end end function actor_on_first_update() savedActorPos = db.actor:position() savedActorDir = db.actor:direction() savedSpeedPos = db.actor:position() savedCamY = device().cam_pos.y - savedActorPos.y -- Debug gizmos if debugMode then initializeGizmos() end -- Get helmet list empty_table(helmets) local ini_eff = ini_file("plugins\\actor_effects.ltx") local n = ini_eff:line_count("settings_helm") for i = 0, n - 1 do local result, id, value = ini_eff:r_line_ex("settings_helm",i,"","") if id and value then helmets[id] = true end end reset(nil, true) RegisterScriptCallback("actor_on_update", checkLedgeGrabbing) RegisterScriptCallback("actor_on_update", speedSaver) end function reset(gizmosArr, force) if debugMode or force then if gizmosArr then for i = 1, #gizmosArr do local gizmo = gizmosArr[i] if gizmos[gizmo] then local v = gizmos[gizmo] if type(v) == "table" then for i = 1, #v do local p = v[i] p.visible = false end else v.visible = false end end end else for k, v in pairs(gizmos) do if type(v) == "table" then for i = 1, #v do local p = v[i] p.visible = false end else v.visible = false end end end end savedClimbPos = nil end function getMaxClimbHeight() return 2.53 end -- I suggest not monkey patch min climb or you'll make climbing small obstacles harder function getMinClimbHeight() return 1.4 end function isWall(position, direction, targetAngle, initialRay) targetAngle = targetAngle or 10 local normal = dgr.get_surface_normal( position, direction, { flags = 2, }, nil, initialRay ) if not normal then return end local angle = abs(deg(asin(normal.y))) return angle < targetAngle, normal end function isFlat(position, direction, targetAngle, initialRay) targetAngle = targetAngle or 30 local normal = dgr.get_surface_normal( position, direction, { flags = 2, }, nil, initialRay ) if not normal then return end local angle = abs(deg(acos(normal.y))) return angle < targetAngle, normal end -- Get bezier curve points for procedural camera movement function getClimbPathEndPoints(actorPos, collisionPos, interPos, climbPos) local p1 = vector():set(actorPos) local p2 = vector():set(interPos.x, actorPos.y, interPos.z) local p3 = vector():set( lerp(actorPos.x, climbPos.x, 0.25), interPos.y, lerp(actorPos.z, climbPos.z, 0.25) ) local p4 = vector():set(climbPos) return p1, p2, p3, p4 end -- TODO: Fix to make it work with exactly (0, -1, 0) function getDownVector() local d = device().cam_dir d.y = 0 d:normalize() d.y = -100 d:normalize() return d end -- Patches -- FDDA local FDDAAnimPlaying = false if enhanced_animations then if enhanced_animations.anim_prepare then local eap = enhanced_animations.anim_prepare enhanced_animations.anim_prepare = function(...) FDDAAnimPlaying = true return eap(...) end end if enhanced_animations.stop_my_item_anim then local eap = enhanced_animations.stop_my_item_anim enhanced_animations.stop_my_item_anim = function(...) FDDAAnimPlaying = false return eap(...) end end if enhanced_animations.stop_my_monster_anim then local eap = enhanced_animations.stop_my_monster_anim enhanced_animations.stop_my_monster_anim = function(...) FDDAAnimPlaying = false return eap(...) end end if ea_callbacks and ea_callbacks.EA_SendScriptCallback and ea_callbacks.EA_RegisterScriptCallback then ea_callbacks.EA_RegisterScriptCallback("ea_on_item_use", function() FDDAAnimPlaying = true end) ea_callbacks.EA_RegisterScriptCallback("ea_on_item_anim_stop", function() FDDAAnimPlaying = false end) end end if zzz_skin and zzz_skin.stop_my_monster_anim then local eap = zzz_skin.stop_my_monster_anim zzz_skin.stop_my_monster_anim = function(...) FDDAAnimPlaying = false return eap(...) end end function checkClimbPrecondition() -- Disable when on ladder if IsMoveState("mcClimb") then return false end -- Disable if FDDA animation plays if FDDAAnimPlaying then return false end -- Disable if BHS is on and one of arms are broken if settings.BHSMode and zzz_player_injuries and zzz_player_injuries.health then local health = zzz_player_injuries.health if (health.leftarm and health.leftarm <= 0) or (health.rightarm and health.rightarm <= 0) then return false end end -- Hardcore mode if settings.hardcoreMode then local weight, minWeight, maxWeight = getActorWeights() if weight >= maxWeight then return false end local health = db.actor.health if health < hardcoreModeProperties.maxClimbHealth then return false end end return true end local tg = 0 function checkLedgeGrabbing() if climbActive then return end if not settings.enable then return reset() end if not checkClimbPrecondition() then return reset() end local actorPos = vector():set( device().cam_pos.x, db.actor:position().y, device().cam_pos.z ) local actorDir = device().cam_dir local realActorPos = db.actor:position() local realActorDir = db.actor:direction() if vecSimilar(realActorPos, savedActorPos, 0.02) and vecSimilar(realActorDir, savedActorDir, 0.02) and not settings.alternativeClimbDetection then return end savedActorPos = realActorPos savedActorDir = realActorDir -- Throttle local t = time_global() if t - tg < settings.throttleCheck then return end tg = max(t, tg + settings.throttleCheck) -- Check if player has possibility to initiate climb local collisionPos local collisionNormal local validCollisionPos = false local climbPos local climbNormal local pos = actorPos local maxY = actorPos.y + getMaxClimbHeight() local minY = actorPos.y + getMinClimbHeight() if settings.alternativeClimbDetection then local savedMaxY = maxY local devicePos = device().cam_pos local deviceDir = device().cam_dir local p = deviceDir:getP() if p < 0 then maxY = actorPos.y + savedCamY else local a = settings.climbTriggerDistance local c = a / cos(p) local v = vector():mad(devicePos, deviceDir, c) maxY = v.y end maxY = clamp(maxY, minY, savedMaxY) end local rayRange = maxY - minY -- print_tip(" minY %s \\n, maxY \\ %s, rayRange %s \\n", minY, maxY, rayRange) local dir = getDownVector() local results = { [0] = {vector():set(actorPos.x, maxY, actorPos.z):mad(dir, rayRange), vector():set(dir):invert()} } -- Shoot rays in array from max climb height to min climb height -- Then check if found surface is flat -- If all succeeds - found a point to climb to -- Collision point will be the previous ray result from climb point, it will determine a wall local rayStepDistance = settings.climbTriggerDistance / settings.raySteps local geometry_ray = dgr.geometry_ray for j = 1, settings.raySteps do local i = rayStepDistance * j local ray = geometry_ray({ ray_range = rayRange, flags = 2, ignore_object = db.actor }) local pos = vector():mad(actorPos, actorDir, i) pos.y = maxY local res = ray:get(pos, dir) if res.success then local flat, normal = isFlat(pos, dir, nil, res) if normal then if not collisionPos then collisionPos = results[j - 1][1] collisionNormal = results[j - 1][2] end if debugMode then if not gizmos.rayLines[j] then gizmos.rayLines[j] = debug_render.add_object(id + idOffset, DBG_ScriptObject.line):cast_dbg_line() idOffset = idOffset + 1 end local p = gizmos.rayLines[j] p.visible = true p.color = fcolor():set(1,1,0,1) p.point_a = pos p.point_b = res.position end if flat then -- Player width check local playerWidthCheck = true if settings.playerWidthCheck then local pos = vector():mad(res.position, actorDir, 0.08) pos.y = maxY local ray = geometry_ray({ ray_range = rayRange, flags = 2, ignore_object = db.actor }) local r = ray:get(pos, dir) if not r.success then playerWidthCheck = true else playerWidthCheck = similar(res.position.y, r.position.y, 0.1) end end if playerWidthCheck then -- Player height check on climbPos -- If height is not enough - stop checking next position, assume that you cant move further local function posCheck(res) local ray = geometry_ray({ ray_range = savedCamY, flags = 2, ignore_object = db.actor }) local heightRes = ray:get(res.position, vector():set(0,1,0)) return not heightRes.success end local valid = posCheck(res) -- Material check local result = res.result if result and result.material_name and result.material_shoot_factor then local name = result.material_name local shoot_factor = result.material_shoot_factor if false or string.find(name, "bush") or string.find(name, "water") -- or self.ray:isMaterialFlag("flShootable") and shoot_factor <= 0.01 -- or (self.ray:isMaterialFlag("flBounceable") and not self.ray:isMaterialFlag("flPassable")) then valid = false end end if debugMode then local p = gizmos.rayLines[j] p.color = valid and fcolor():set(0,1,0,1) or fcolor():set(1,0,0,1) for k = j + 1, #gizmos.rayLines do local x = gizmos.rayLines[k] x.visible = false end end validCollisionPos = valid climbPos = res.position climbNormal = normal if not validCollisionPos then break end -- Advance position a bit further and check it as well for better result -- If successfull - use that, otherwise use result for _, v in ipairs({0.05, 0.025}) do local pos = vector():mad(actorPos, actorDir, i + v) pos.y = maxY local ray = geometry_ray({ ray_range = rayRange, flags = 2, ignore_object = db.actor }) local res = ray:get(pos, dir) if res.success then local flat, normal = isFlat(pos, dir, nil, res) if normal and flat then local valid = posCheck(res) if valid then climbPos = res.position climbNormal = normal break end end end end break end end else if debugMode then if not gizmos.rayLines[j] then gizmos.rayLines[j] = debug_render.add_object(id + idOffset, DBG_ScriptObject.line):cast_dbg_line() idOffset = idOffset + 1 end local p = gizmos.rayLines[j] p.visible = true p.color = fcolor():set(1,0,0,1) p.point_a = pos p.point_b = res.position end results[j] = {res.position, vector():set(dir):invert()} end else if debugMode then if not gizmos.rayLines[j] then gizmos.rayLines[j] = debug_render.add_object(id + idOffset, DBG_ScriptObject.line):cast_dbg_line() idOffset = idOffset + 1 end local p = gizmos.rayLines[j] p.visible = true p.color = fcolor():set(1,0,0,1) p.point_a = pos p.point_b = vector():mad(pos, dir, rayRange) end collisionPos = nil collisionNormal = nil validCollisionPos = false results[j] = {vector():mad(pos, dir, rayRange), vector():set(dir):invert()} end end if not collisionPos then reset() return end -- Draw results if debugMode then gizmos.sphereStart.visible = true gizmos.sphereStart.color = fcolor():set(1,0,0,1) local scale_mat = matrix():identity():scale(0.1,0.1,0.1) local pos_mat = matrix():translate(collisionPos) local mat = matrix():mul(pos_mat, scale_mat) gizmos.sphereStart.matrix = mat end if not validCollisionPos then reset({"pathLines", "sphereEnd", "climbNormal", "sphereInter", "sphereActor"}) return end if not climbPos then reset({"pathLines", "sphereEnd", "climbNormal", "sphereInter", "sphereActor"}) return end -- Compute climb path -- Interpos is point that will aid in climb path computing and used as a collision check in order to not climb through walls mostly local interPos = vector():set( collisionPos.x, climbPos.y + 0.15, collisionPos.z ) -- Don't init climb if points arent on the screen local climbPosUi = game.world2ui(climbPos) local interPosUi = game.world2ui(interPos) if not ((climbPosUi.x > 0 and climbPosUi.x < 1024 and climbPosUi.y > 0 and climbPosUi.y < 768) or (interPosUi.x > 0 and interPosUi.y < 1024 and interPosUi.y > 0 and interPosUi.y < 768)) then reset({"pathLines", "sphereEnd", "climbNormal", "sphereInter", "sphereActor"}) return end if debugMode then gizmos.sphereEnd.visible = true gizmos.sphereEnd.color = fcolor():set(0,1,0,1) local scale_mat = matrix():identity():scale(0.1,0.1,0.1) local pos_mat = matrix():translate(climbPos) local mat = matrix():mul(pos_mat, scale_mat) gizmos.sphereEnd.matrix = mat gizmos.climbNormal.visible = true gizmos.climbNormal.color = fcolor():set(0,1,0,1) gizmos.climbNormal.point_a = climbPos gizmos.climbNormal.point_b = vector():mad(climbPos, climbNormal, 1) gizmos.sphereInter.visible = true gizmos.sphereInter.color = fcolor():set(1,1,0,1) local scale_mat = matrix():identity():scale(0.1,0.1,0.1) local pos_mat = matrix():translate(interPos) local mat = matrix():mul(pos_mat, scale_mat) gizmos.sphereInter.matrix = mat gizmos.sphereActor.visible = true gizmos.sphereActor.color = fcolor():set(1,0,0,1) local scale_mat = matrix():identity():scale(0.1,0.1,0.1) local pos_mat = matrix():translate(actorPos) local mat = matrix():mul(pos_mat, scale_mat) gizmos.sphereActor.matrix = mat end -- Collision check for climb path - ray cast to interPos local adjActorPos = vector():set(actorPos) adjActorPos.y = adjActorPos.y + 0.02 local posToInterVec = vector():set(interPos):sub(adjActorPos) local collisionCheckRay = geometry_ray({ ray_range = posToInterVec:magnitude(), flags = 2, ignore_object = db.actor }) local pos = adjActorPos local dir = vector():set(posToInterVec):normalize() local res = collisionCheckRay:get(pos, dir) local collisionCheck = not res.success if collisionCheck then -- Collision check for climb path - ray cast to climbPos from interPos local posToInterVec = vector():set( climbPos.x, math.max(interPos.y, climbPos.y + 0.05), climbPos.z ):sub(interPos) local collisionCheckRay = geometry_ray({ ray_range = posToInterVec:magnitude(), flags = 2, ignore_object = db.actor }) local pos = interPos local dir = vector():set(posToInterVec):normalize() local res = collisionCheckRay:get(pos, dir) collisionCheck = not res.success end if debugMode then local pathLinesNum = #gizmos.pathLines local p1, p2, p3, p4 = getClimbPathEndPoints(actorPos, collisionPos, interPos, climbPos) for i = 1, pathLinesNum do local k = i / pathLinesNum - 1 / pathLinesNum local nextK = (i+1) / pathLinesNum - 1 / pathLinesNum local p = gizmos.pathLines[i] p.visible = true p.color = collisionCheck and fcolor():set(0,1,0,1) or fcolor():set(1,0,0,1) p.point_a = cubicBezier(p1, p2, p3, p4, k) p.point_b = cubicBezier(p1, p2, p3, p4, nextK) end gizmos.collisionLine.visible = true gizmos.collisionLine.color = collisionCheck and fcolor():set(0,1,0,1) or fcolor():set(1,0,0,1) gizmos.collisionLine.point_a = adjActorPos gizmos.collisionLine.point_b = vector():mad(actorPos, vector():set(posToInterVec):normalize(), res.distance) end if not collisionCheck then savedClimbPos = nil return end savedCollisionPos = collisionPos savedClimbPos = climbPos savedInterPos = interPos savedClimbNormal = climbNormal if debugMode then if onScreenCheck() then gizmos.sphereInter.color = fcolor():set(0,1,0,1) end end end -- Weight factor -- Follows vanilla CoP behaviour (no penalty with less than 50% of weight) -- The maxWeight is minumum 60 kg in order to give more stamina with starter equipment function getActorMaxWalkWeight() local maxWeight = db.actor:get_actor_max_walk_weight() local outfit = db.actor:item_in_slot(7) local backpack = db.actor:item_in_slot(13) maxWeight = maxWeight + (outfit and outfit:get_additional_max_weight() or 0) maxWeight = maxWeight + (backpack and backpack:get_additional_max_weight() or 0) db.actor:iterate_belt(function(owner, obj) local c_arty = obj:cast_Artefact() maxWeight = maxWeight + (c_arty and c_arty:AdditionalInventoryWeight() or 0) end) if settings.hardcoreMode then maxWeight = min(maxWeight, hardcoreModeProperties.maxClimbWeight) end return maxWeight end function getActorWeights() local maxWeight = max(60, getActorMaxWalkWeight()) local minWeight = maxWeight / 2 local weight = db.actor:get_total_weight() return weight, minWeight, maxWeight end -- Animation speed modifier -- 33% speed reduction when player is at max weight -- Full speed if weight is below half of max walk weight -- Slight speed reduction at low stamina function getAnimationSpeedModifier() local weight, minWeight, maxWeight = getActorWeights() local k = mapRangeClamped(weight, minWeight, maxWeight, 0, 0.33) k = 1 - k local staminaK = mapRangeClamped(db.actor.power, 0, 0.66, 0.87, 1) k = k * staminaK -- Reduce anim speed depending on outfit local outfit = db.actor:item_in_slot(7) if outfit then local kind = SYS_GetParam(0, outfit:section(), "kind", -1) local repair_type = SYS_GetParam(0, outfit:section(), "repair_type", -1) if repair_type == "outfit_exo" then k = k * 0.85 elseif kind == "o_heavy" then k = k * 0.92 end end -- Randomize a bit k = k * (1 + random_float(0, 0.12) * settings.animationRandomization) -- print_tip(" weight: %s \\n minWeight: %s \\n maxWeight: %s \\n k %s \\n staminaK %s", weight, minWeight, maxWeight, k, staminaK) return k end -- Stamina drain modifier function getStaminaDrain() local weight, minWeight, maxWeight = getActorWeights() local k = mapRangeClamped(weight, minWeight, maxWeight, 0.1, settings.hardcoreMode and 0.7 or 0.55) k = k * settings.staminaDrain k = max(0.1, k) -- print_tip(" weight: %s \\n minWeight: %s \\n maxWeight: %s \\n getStaminaDrain %s ", weight, minWeight, maxWeight, k) return k end function getSatietyDrain() local weight, minWeight, maxWeight = getActorWeights() local k = mapRangeClamped(weight, minWeight, maxWeight, 0.001, 0.0045) return k end -- Get sound for climbing outfitSoundFolder = { [0] = [[ledge_grabbing\vaulting\naked\]], o_light = [[ledge_grabbing\vaulting\light\]], o_medium = [[ledge_grabbing\vaulting\medium\]], o_heavy = [[ledge_grabbing\vaulting\heavy\]], outfit_exo = [[ledge_grabbing\vaulting\exo\]], } gruntSoundFolder = { [0] = [[ledge_grabbing\grunts\naked\]], mask = [[ledge_grabbing\grunts\mask\]], } soundFolderCache = {} function populateSoundFolderCache(folder) if not folder then -- print_tip("![Ledge Grabbing.populateSoundFolderCache] invalid folder") return {} end if folder:sub(-1) ~= [[\]] then folder = folder .. [[\]] end local t = {} local fileList = getFS():file_list_open("$game_sounds$", folder, bit_or(FS.FS_ListFiles, FS.FS_RootOnly)) local count = fileList and fileList:Size() or 0 for i = 0, count - 1 do local fileExt = fileList:GetAt(i) local file = fileExt:sub(1, -5) local ext = (function() local x = str_explode(fileExt, "%.") if #x < 2 then return "" end return x[#x] end)() if ext == "ogg" then file = folder .. file tinsert(t, file) end end return t end function getSoundFromFolder(folder) -- Get sound from a folder if not soundFolderCache[folder] then soundFolderCache[folder] = populateSoundFolderCache(folder) end local sound = randomFromArray(soundFolderCache[folder]) if not sound then -- print_tip("![Ledge Grabbing.getSoundFromFolder] no sounds found in %s", folder) return end local soundObj = xr_sound.get_safe_sound_object(sound) return soundObj end function getOutfitSound() -- Find folder by suit local outfit = db.actor:item_in_slot(7) local folder if not outfit then folder = outfitSoundFolder[0] else local kind = SYS_GetParam(0, outfit:section(), "kind", -1) local repair_type = SYS_GetParam(0, outfit:section(), "repair_type", -1) folder = outfitSoundFolder[repair_type] or outfitSoundFolder[kind] or outfitSoundFolder.o_medium end return getSoundFromFolder(folder) end function getGruntSound() local outfit = db.actor:item_in_slot(7) local helmet = db.actor:item_in_slot(12) local folder if (helmet and helmets[helmet:section()]) or (outfit and helmets[outfit:section()]) then folder = gruntSoundFolder.mask else folder = gruntSoundFolder[0] end return getSoundFromFolder(folder) end function getSounds() local o = getOutfitSound() local g = getGruntSound() return o, g end function canPlayGruntSound() return random() < settings.gruntProbability end AddScriptCallback("actor_on_climb_start") AddScriptCallback("actor_on_climb_end") function amIStuck() local geometry_ray = dgr.geometry_ray local ray = geometry_ray({ ray_range = savedCamY, flags = 2, ignore_object = db.actor }) local pos = db.actor:position() local dir = VEC_Y local res = ray:get(pos, dir) if debugMode then if res.success then print_tip("stuck") else print_tip("not stuck") end end return res.success end function tryToClimb(fromStanding) if climbActive then return end if not savedClimbPos then return end -- Read animation data and set params local animationSpeedModifier = getAnimationSpeedModifier() local animationData = dup_table(demonized_ledge_grabbing_animation_data.data) local animationSpeed = animationData.animationSpeed * animationSpeedModifier local startTime = time_global() local animationStartTime = 0 local animationEndTime = animationData.calculatedTime local endTime = startTime + animationData.calculatedTime / animationSpeed * 1000 local animInTime = animationData.animInFrame / animationData.originalFps local animOutTime = animationData.animOutFrame / animationData.originalFps local animRotationModifier = animationData.rotationModifier local gruntSoundTime = animationData.gruntSoundFrame / animationData.originalFps local gruntSoundPlayed = false -- Adjust hud motion local hudMotionSpeed = animationData.hudMotionSpeed local hudMotionStarted = false -- Get positions climbActive = true local climbPos = vector():set(savedClimbPos) local interPos = vector():set(savedInterPos) local collisionPos = vector():set(savedCollisionPos) local actorPos = db.actor:position() -- Get difference from camera height to position local camY = savedCamY -- make playerPos during climb so it will stay there during camera anim -- Adjust it to fit animation local playerPosOffsetFromWall = 0.037 local playerPosOffset = vector():set(0, -0.024, 0) local d = vector():set(collisionPos):sub(climbPos) d.y = 0 d:normalize() local playerPos = vector():set( collisionPos.x, climbPos.y - camY, collisionPos.z ) playerPos:mad(d, playerPosOffsetFromWall):add(playerPosOffset) -- Adjusted positions by difference from camera local adjustedClimbPos = vector():set(climbPos):add(vector():set(0, camY, 0)) local adjustedInterPos = vector():set(interPos):add(vector():set(0, camY, 0)) local adjustedCollisionPos = vector():set(collisionPos):add(vector():set(0, camY, 0)) local adjustedActorPos = vector():set(actorPos):add(vector():set(0, camY, 0)) local camDir = device().cam_dir local camDirHPB = vector():set( camDir:getH(), camDir:getP(), 0 ) local p1, p2, p3, p4 = getClimbPathEndPoints(adjustedActorPos, adjustedCollisionPos, adjustedInterPos, adjustedClimbPos) -- Distance from collision point to camera for better hands display local startCameraDistance = 0.4 -- Camera offset local startCameraOffset = vector():set(0, -0.5, 0) -- Camera offset when in animIn state local animInCameraOffset = vector():set(0, 0.1, 0) local d = vector():set(collisionPos):sub(climbPos) d.y = 0 d:normalize() local p = vector():set(playerPos) p.y = p.y + camY local startCameraPos = vector():mad(p, d, startCameraDistance) startCameraPos:add(startCameraOffset) -- Randomize prebaked camera movement local randomPositionOffset = randomPlusMinus(15) * settings.animationRandomization local randomRotationCoeffX = 1 + random_float(-0.15, 0.25) * settings.animationRandomization local randomRotationCoeffY = 1 + random_float(-0.15, 0.25) * settings.animationRandomization local randomRotationCoeffZ = 1 + random_float(-0.15, 0.25) * settings.animationRandomization -- print_tip("playerPos %s \\n camY %s", playerPos, camY) -- Gameplay params local staminaDrain = getStaminaDrain() local staminaDrainPerMs = staminaDrain / (animationEndTime * 1000 / animationSpeed) local initStamina = db.actor.power local targetStamina = initStamina - staminaDrain local satietyDrain = getSatietyDrain() local satietyDrainPerMs = satietyDrain / (animationEndTime * 1000 / animationSpeed) local initSatiety = db.actor:cast_Actor():conditions():GetSatiety() local targetSatiety = initSatiety - satietyDrain -- Hide detector and weapon immediately (move to ruck works) local activeSlot = db.actor:active_slot() local activeWpn = db.actor:active_item() local activeDetector = db.actor:active_detector() local activeWpnId local activeDetectorId if activeWpn or activeDetector then if activeWpn then db.actor:move_to_ruck(activeWpn) activeWpnId = activeWpn:id() end if activeDetector then db.actor:move_to_ruck(activeDetector) activeDetectorId = activeDetector:id() end end -- Uncrouch if was crouched if IsMoveState("mcCrouch") then if (get_console():get_bool("crouch_toggle")) then level.press_action(bind_to_dik(key_bindings.kCROUCH)) else level.release_action(bind_to_dik(key_bindings.kCROUCH)) end end local CubicEaseInOut = drf.CubicEaseInOut local QuadraticEaseIn = drf.QuadraticEaseIn local QuadraticEaseOut = drf.QuadraticEaseOut local QuadraticEaseInOut = drf.QuadraticEaseInOut local SineEaseOut = drf.SineEaseOut local camSmoothing = fromStanding and CubicEaseInOut or SineEaseOut -- Set actor properties game.only_allow_movekeys(true) game.set_actor_allow_ladder(false) -- Callbacks SendScriptCallback("actor_on_climb_start") SetEvent("actor_climbing", true) local firstUpdate = true local savedHudWeapon = get_console_cmd(1, "hud_weapon") -- 2 ticks required for hiding weapon forcefully with move to ruck for proper animation nextTick(function() local sound, gruntSound = getSounds() if sound then sound:play(db.actor, 0, sound_object.s2d) sound.volume = settings.soundVolume sound.frequency = animationSpeedModifier + randomPlusMinus(0.07) end local gruntSoundCanPlay = gruntSound and canPlayGruntSound() local k = 0 local performClimb performClimb = function() -- If close to animation end - stop and give control to player if k > 0.98 then local p = cubicBezier(p1, p2, p3, p4, k) p.y = p.y - camY + 0.07 -- Small offset for better collision handling db.actor:set_actor_direction(-camDirHPB.x) db.actor:set_actor_position(p) level.remove_cam_custom_position_direction() game.stop_hud_motion() local unstuckFunc unstuckFunc = function() if not settings.unstuckCheck then UnregisterScriptCallback("actor_on_update", unstuckFunc) return end local stuck = amIStuck() -- If height is too small for the player - push him upwards if stuck then if debugMode then print_tip("height is too small") end local geometry_ray = dgr.geometry_ray local ray = geometry_ray({ ray_range = getMaxClimbHeight(), flags = 2, ignore_object = db.actor }) local pos = db.actor:position() pos.y = pos.y + camY + 0.1 local dir = getDownVector() local res = ray:get(pos, dir) if res.success then res.position.y = res.position.y + 0.07 db.actor:set_actor_position(res.position) UnregisterScriptCallback("actor_on_update", unstuckFunc) end else UnregisterScriptCallback("actor_on_update", unstuckFunc) end end RegisterScriptCallback("actor_on_update", unstuckFunc) -- Restore weapons if had if activeWpnId or activeDetectorId then if activeWpnId then local obj = level.object_by_id(activeWpnId) if obj then db.actor:move_to_slot(obj, activeSlot) nextTick(function() db.actor:activate_slot(activeSlot) return true end, 2) end end if activeDetectorId then local obj = level.object_by_id(activeDetectorId) if obj then db.actor:move_to_slot(obj, 9) -- Time event for proper animation play CreateTimeEvent("ledge_grabbing", "activate_detector", 0.5, function() obj:switch_state(1) return true end) end end end -- Set actor properties game.only_allow_movekeys(false) game.set_actor_allow_ladder(true) -- Callbacks UnregisterScriptCallback("actor_on_update", performClimb) -- Set flag after some time CreateTimeEvent("ledge_grabbing", "reset", 0.5, function() climbActive = false SendScriptCallback("actor_on_climb_end") SetEvent("actor_climbing", nil) UnregisterScriptCallback("actor_on_update", unstuckFunc) reset() return true end) return end local animTime = (time_global() - startTime) / 1000 * animationSpeed if gruntSoundCanPlay and not gruntSoundPlayed and animTime > gruntSoundTime then gruntSoundPlayed = true gruntSound:play(db.actor, 0, sound_object.s2d) gruntSound.volume = settings.gruntVolume gruntSound.frequency = 1 + randomPlusMinus(0.07) end -- Get camera params from animation data local locationX = animationData.curves.locationX local delta, lo, hi = lookup(locationX, animTime) local locationXRes = cubicBezier2D(locationX[lo][1], locationX[lo][2], locationX[lo][3], locationX[lo][4], delta) local animDelta, animLo, animHi = delta, lo, hi local locationY = animationData.curves.locationY local delta, lo, hi = lookup(locationY, animTime) local locationYRes = cubicBezier2D(locationY[lo][1], locationY[lo][2], locationY[lo][3], locationY[lo][4], delta) local locationZ = animationData.curves.locationZ local delta, lo, hi = lookup(locationZ, animTime) local locationZRes = cubicBezier2D(locationZ[lo][1], locationZ[lo][2], locationZ[lo][3], locationZ[lo][4], delta) local rotationX = animationData.curves.rotation_eulerX local delta, lo, hi = lookup(rotationX, animTime * hudMotionSpeed) local rotationXRes = cubicBezier2D(rotationX[lo][1], rotationX[lo][2], rotationX[lo][3], rotationX[lo][4], delta) local rotationY = animationData.curves.rotation_eulerY local delta, lo, hi = lookup(rotationY, animTime * hudMotionSpeed) local rotationYRes = cubicBezier2D(rotationY[lo][1], rotationY[lo][2], rotationY[lo][3], rotationY[lo][4], delta) local rotationZ = animationData.curves.rotation_eulerZ local delta, lo, hi = lookup(rotationZ, animTime * hudMotionSpeed) local rotationZRes = cubicBezier2D(rotationZ[lo][1], rotationZ[lo][2], rotationZ[lo][3], rotationZ[lo][4], delta) -- Get camera relative position from animation local cameraOffset = vector():set( locationXRes.y, locationYRes.y, locationZRes.y ) -- Rotate relative position to player's direction cameraOffset = vector_rotate_y(cameraOffset, deg(camDirHPB.x) + randomPositionOffset) -- Set camera position local cameraPos = vector():set(startCameraPos):add(cameraOffset) -- Calculate procedural camera transform k = normalize(time_global(), startTime, endTime) k = min(1, k) local cameraPos2 = cubicBezier(p1, p2, p3, p4, camSmoothing(k)) -- Interpolate between procedural and animation camera in animInTime local animInK = min(1, normalize(animTime, 0, animInTime)) animInK = QuadraticEaseInOut(animInK) local resultPosition = vecLerp(cameraPos2, cameraPos, animInK) -- Move player with camera then stop it to playerPos local currentPlayerPos = vecLerp( vector():set( resultPosition.x, resultPosition.y - camY, resultPosition.z ):sub(animInCameraOffset), playerPos, animInK ) -- Calculate position when returning from animation to procedural local animOutK = clamp(normalize(animTime, animOutTime, animationEndTime), 0, 1) animOutK = QuadraticEaseInOut(animOutK) resultPosition = vecLerp( resultPosition, cameraPos2, animOutK ) -- print_tip(" locationXRes: %s \\n locationYRes: %s \\n locationZRes: %s \\n delta %s \\n lo %s \\n hi %s \\n animInK %s \\n animOutK %s \\n animTime %s \\n modifier %s", locationXRes, locationYRes, locationZRes, animDelta, animLo, animHi, animInK, animOutK, animTime, animationSpeedModifier) -- print_tip(" staminaDrain: %s \\n staminaDrainPerMs: %s \\n initStamina: %s \\n targetStamina: %s \\n currentStamina: %s \\n delta: %s ", staminaDrain, staminaDrainPerMs, initStamina, targetStamina, db.actor.power, db.actor.power - targetStamina) -- Calculate rotations -- Interpolate look to grab point local dirFromActorPosToGrabPos = vector():set( interPos.x - resultPosition.x, climbPos.y - resultPosition.y, interPos.z - resultPosition.z ):normalize() dirFromActorPosToGrabPos = vector():set( camDirHPB.x, dirFromActorPosToGrabPos:getP(), 0 ) local cameraRot = vecLerp( camDirHPB, dirFromActorPosToGrabPos, animInK ) local animRotationK = clamp(normalize(animTime, animInTime, animOutTime), 0, 1) animRotationK = QuadraticEaseInOut(animRotationK) -- Interpolate look to climbPos local dirFromActorPosToClimbPos = vector():set( climbPos.x - resultPosition.x, climbPos.y - resultPosition.y, climbPos.z - resultPosition.z ):normalize() dirFromActorPosToClimbPos = vector():set( camDirHPB.x, dirFromActorPosToClimbPos:getP() / 4, 0 ) cameraRot = vecLerp( cameraRot, dirFromActorPosToClimbPos, animRotationK ) -- Apply additive from prebaked animation cameraRot = vector():set( cameraRot.x + rotationXRes.y * animRotationModifier * randomRotationCoeffX, cameraRot.y + rotationYRes.y * animRotationModifier * randomRotationCoeffY, cameraRot.z + rotationZRes.y * animRotationModifier * randomRotationCoeffZ ) -- Return to initial rotation when returning from animation to procedural cameraRot = vecLerp( cameraRot, vector():set( camDirHPB.x, 0, 0 ), animOutK ) -- 1 frame camera delay hack fix if firstUpdate then exec_console_cmd("hud_weapon 0") else db.actor:set_actor_position(currentPlayerPos, true) db.actor:set_actor_direction(-camDirHPB.x) exec_console_cmd("hud_weapon " .. (savedHudWeapon and 1 or 0)) end level.set_cam_custom_position_direction(resultPosition, cameraRot, 1, true, false) -- Start hud motion on next tick if not hudMotionStarted then hudMotionStarted = true nextTick(function() game.play_hud_motion(2, "item_anm_ledge_grabbing", "anm_climb", true, animationSpeed * hudMotionSpeed) return true end) end -- Drain stamina local staminaK = QuadraticEaseIn(k) + 1 db.actor:change_power(-staminaDrainPerMs * staminaK * device().time_delta) -- Drain satiety local satietyK = k + 1 db.actor:change_satiety(-satietyDrainPerMs * satietyK * device().time_delta) firstUpdate = false end RegisterScriptCallback("actor_on_update", performClimb) return true end, 2) return true end function onScreenCheck() if not savedClimbPos then return end if not savedInterPos then return end -- Don't init climb if points arent on the screen local climbPosUi = game.world2ui(savedClimbPos) local interPosUi = game.world2ui(savedInterPos) local topBorder = 768 * 1/3 local bottomBorder = 768 * 2/3 local leftBorder = 1024 * 1/3 local rightBorder = 1024 * 2/3 if (climbPosUi.x >= leftBorder and climbPosUi.x <= rightBorder and climbPosUi.y >= topBorder and climbPosUi.y <= bottomBorder) or (interPosUi.x >= leftBorder and interPosUi.y <= rightBorder and interPosUi.y >= topBorder and interPosUi.y <= bottomBorder) then return true end return false end function checkJump(pressed) if pressed == true then -- If savedClimbPos is similar to camera height or below it - init climb immediately if savedClimbPos and (savedClimbPos.y < device().cam_pos.y or similar(savedClimbPos.y, device().cam_pos.y, 0.35)) then if tryToClimb(true) then UnregisterScriptCallback("actor_on_update", checkJump) end elseif savedClimbPos and onScreenCheck() then if tryToClimb(true) then UnregisterScriptCallback("actor_on_update", checkJump) end end elseif key_state(settings.keybind) == 1 then -- Otherwise try after sometime if (time_global() - savedJumpPressedTime) > settings.jumpTriggerTime then if savedSpeed.y > -0.005 then if tryToClimb(savedSpeed.y < 0.001) then UnregisterScriptCallback("actor_on_update", checkJump) return end else -- print_tip("Not jumping stop check, %s", savedSpeed) UnregisterScriptCallback("actor_on_update", checkJump) return end end else UnregisterScriptCallback("actor_on_update", checkJump) end end function toggleDebugMode() debugMode = not debugMode if debugMode then initializeGizmos() else reset(nil, true) end end function on_key_press(dik) if (settings.inputMethod == "buttonPress" or settings.inputMethod == "both") and dik == settings.keybind and ui_mcm.get_mod_key(settings.second_key) then if settings.modifier == 1 then if ui_mcm.double_tap("demonized_ledge_grabbing", dik) then tryToClimb(savedSpeed.y < 0.001) end elseif settings.modifier == 0 then tryToClimb(savedSpeed.y < 0.001) end end if dik == DIK_keys.DIK_J then -- toggleDebugMode() end if dik == settings.keybind and ui_mcm.get_mod_key(settings.second_key) and settings.inputMethod ~= "buttonPress" then savedJumpPressedTime = time_global() checkJump(true) RegisterScriptCallback("actor_on_update", checkJump) end end function on_key_hold(dik) if (settings.inputMethod == "buttonPress" or settings.inputMethod == "both") and dik == settings.keybind and ui_mcm.get_mod_key(settings.second_key) then if settings.modifier == 2 then if ui_mcm.key_hold("demonized_ledge_grabbing", dik) then tryToClimb() end end end end -- Prevent strike damage when climbing function actor_on_before_hit(shit, bone_id, flags) if not GetEvent("actor_climbing") then return end if (not shit.draftsman or (shit.draftsman and (shit.draftsman:id() == 0 or shit.draftsman:id() == 65535 or shit.draftsman:id() == -1))) and shit.type == hit.strike then shit.power = 0 flags.ret_value = false end end function on_game_start() assert(level.set_cam_custom_position_direction, "[Ledge Grabbing] Modded Exes are required for mod to work, download from https://github.com/themrdemonized/STALKER-Anomaly-modded-exes") assert(debug_render, "[Ledge Grabbing] Modded Exes are required for mod to work, download from https://github.com/themrdemonized/STALKER-Anomaly-modded-exes") RegisterScriptCallback("on_key_press", on_key_press) RegisterScriptCallback("on_key_hold", on_key_hold) RegisterScriptCallback("on_loading_screen_key_prompt", actor_on_first_update) RegisterScriptCallback("actor_on_first_update", load_settings) RegisterScriptCallback("on_option_change", load_settings) RegisterScriptCallback("actor_on_before_hit", actor_on_before_hit) end