local animationPal = require("maskCollection/scripts/animationPal")
local pathfinding = require("maskCollection/scripts/pathfinding")
local tilemap = require("maskCollection/scripts/tilemap")
local enemies = require("maskCollection/scripts/enemies")
local weirdGoop = require("maskCollection/scripts/weirdGoop")

local battlePlayer = require("scripts/battlePlayer")
local battleCamera = require("scripts/battleCamera")

local onlinePlay = require("scripts/onlinePlay")
local onlinePlayNPC = require("scripts/onlinePlay_npc")
local onlinePlayPlayers = require("scripts/onlinePlay_players")

local npcManager = require("npcManager")
local npcutils = require("npcs/npcutils")

local fauxPlayer = {}


fauxPlayer.idList = {}
fauxPlayer.idMap = {}


fauxPlayer.sharedSettings = {
	gfxwidth = 32,
	gfxheight = 32,

	width = 32,
	height = 32,

	gfxoffsetx = 0,
	gfxoffsety = 0,

	frames = 1,
	framestyle = 0,
	framespeed = 8,

	luahandlesspeed = true,
	nowaterphysics = false,
	cliffturn = false,
	staticdirection = true,

	npcblock = false,
	npcblocktop = false,
	playerblock = false,
	playerblocktop = false,

	nohurt = true,
	nogravity = true,
	noblockcollision = false,
	notcointransformable = false,
	nofireball = false,
	noiceball = false,
	noyoshi = false,

	jumphurt = false,
	spinjumpsafe = false,
	harmlessgrab = true,
	harmlessthrown = true,
	nowalldeath = false,

	terminalvelocity = 12,


	health = 3,


	walkSpeed = 3,
	runSpeed = 6,

	walkAcceleration = 0.1,
	runAcceleration = 0.05,
	turnAroundAcceleration = 0.28,

	idleDecelerationGrounded = 0.07,
	idleDecelerationAir = 0,
	idleDecelerationStopThreshold = 0.18,

	gravity = 0.4,


	stompJumpEnabled = true,
	stompJumpMinSpeedY = -10,
	stompJumpMaxSpeedY = -6,
	stompJumpCooldown = 8,

	stompHitSpeed = -5.7,


	skidEnabled = true,
	skidEffectID = 74,
	skidEffectTimeMin = 2,
	skidEffectTimeMax = 2,
	skidEffectOffsetX = -8,
	skidEffectOffsetY = -5,


	linkStabEnabled = false,

	linkStabCooldown = 12,
	linkStabJumpCooldown = 32,

	linkStabAnticipationTime = 5,
	linkStabActiveTime = 9,
	linkStabLagTime = 16,

	linkStabHitboxWidth = 34,
	linkStabHitboxHeight = 6,
	linkStabHitboxOffsetX = -2,
	linkStabHitboxOffsetY = -36,

	linkCrouchStabActiveTime = 8,
	linkCrouchStabLagTime = 16,

	linkCrouchStabHitboxWidth = 34,
	linkCrouchStabHitboxHeight = 8,
	linkCrouchStabHitboxOffsetX = -2,
	linkCrouchStabHitboxOffsetY = -14,

	linkStabRecoilSpeed = -3,
	linkStabRecoilDecelerationGrounded = 0.1,
	linkStabRecoilDecelerationAir = 0.05,
	linkStabRecoilTime = 48,


	linkShieldEnabled = false,
	linkShieldHitboxWidth = 4,
	linkShieldHitboxHeight = 24,
	linkShieldHitboxOffsetX = 14,
	linkShieldHitboxOffsetY = -28,
	linkShieldHitboxDuckOffset = 8,

	jumpDuckEnabled = false,


	harmKnockbackEnabled = false,
	harmKnockbackSpeedX = -3,
	harmKnockbackSpeedY = -7,


	harmInvincibilityTime = 96,


	jumpSound = Misc.resolveSoundFile("maskCollection/resources/sound/fauxPlayer/jump"),
	skidSound = Misc.resolveSoundFile("maskCollection/resources/sound/fauxPlayer/skid"),
	stompSound = Misc.resolveSoundFile("maskCollection/resources/sound/fauxPlayer/stomp"),
	hurtSound = Misc.resolveSoundFile("maskCollection/resources/sound/fauxPlayer/hurt"),
	deathSound = Misc.resolveSoundFile("maskCollection/resources/sound/fauxPlayer/death"),
	linkStabSound = Misc.resolveSoundFile("maskCollection/resources/sound/fauxPlayer/linkStab"),

	soundVolume = 1,
}


local onlineHandlingConfig = {
	getExtraData = function(v)
		local data = v.data

		if not data.initialised then
			fauxPlayer.initialise(v)
		end

		return {
			targetPlayerIdx = (data.targetPlayer ~= nil and data.targetPlayer.idx) or 0,

			health = data.health,

			state = data.state,
			stateTimer = data.stateTimer,

			jumpAnticipationEdgeID = (data.jumpAnticipationEdge ~= nil and data.jumpAnticipationEdge.id) or 0,
			jumpAnticipationDone = data.jumpAnticipationDone,

			stompJumpCooldown = data.stompJumpCooldown,
			linkStabCooldown = data.linkStabCooldown,

			invincibilityTimer = data.invincibilityTimer,
		}
	end,
	setExtraData = function(v,receivedData)
		local data = v.data

		if not data.initialised then
			fauxPlayer.initialise(v)
		end

		if receivedData.targetPlayerIdx > 0 then
			data.targetPlayer = Player(receivedData.targetPlayerIdx)
		end

		if receivedData.jumpAnticipationEdgeID > 0 then
			data.jumpAnticipationEdge = pathfinding.edgeIDMap[receivedData.jumpAnticipationEdgeID]
			data.jumpAnticipationDone = receivedData.jumpAnticipationDone
		else
			data.jumpAnticipationEdge = nil
		end

		data.health = receivedData.health

		data.state = receivedData.state
		data.stateTimer = receivedData.stateTimer

		data.stompJumpCooldown = receivedData.stompJumpCooldown
		data.linkStabCooldown = receivedData.linkStabCooldown

		data.invincibilityTimer = receivedData.invincibilityTimer
	end,
	shouldStealFunc = function(v)
		local data = v.data

		if not data.initialised then
			return false
		end

		return onlinePlayPlayers.ownsPlayer(data.targetPlayer)
	end,
}


function fauxPlayer.register(npcID)
	npcManager.registerEvent(npcID, fauxPlayer, "onTickNPC")
	npcManager.registerEvent(npcID, fauxPlayer, "onDrawNPC")

    table.insert(fauxPlayer.idList,npcID)
    fauxPlayer.idMap[npcID] = true

	enemies.config[npcID] = {
        createDeathGoop = true,
        outlineColor = weirdGoop.OUTLINE_COLOR.PINK,

        droppedMaskCount = 3,
    }

	onlinePlayNPC.onlineHandlingConfig[npcID] = onlineHandlingConfig
end


local STATE = {
	NORMAL = 0,
	LINK_STAB = {
		NORMAL = {
			ANTICIPATION = 1,
			ACTIVE = 2,
			LAG = 3,
		},
		CROUCH = {
			ACTIVE = 4,
			LAG = 5,
		},
		RECOIL = 6,
	},
	HARM_KNOCKBACK = 7,
}


local playerShieldHitboxWidth = 4
local playerShieldHitboxHeight = 24
local playerShieldHitboxOffsetX = 14
local playerShieldHitboxOffsetY = -28
local playerShieldHitboxDuckOffset = 8


local function getClosestActivePlayer(x,y,biasedPlayer)
    local closestSquareDistance = math.huge
    local closestPlayer = player

    for _,p in ipairs(battlePlayer.getActivePlayers()) do
        local distX = p.x + p.width*0.5 - x
        local distY = p.y + p.height*0.5 - y

		if p == biasedPlayer then
			distX = distX*0.5
			distY = distY*0.5
		end

        local squareDistance = distX*distX + distY*distY

        if squareDistance < closestSquareDistance then
            closestSquareDistance = squareDistance
            closestPlayer = p
        end
    end

    return closestPlayer
end


function fauxPlayer.initialise(v)
	local config = NPC.config[v.id]
	local data = v.data

	data.animator = animationPal.createAnimator{animationSet = config.animationSet,startAnimation = "idle"}

	data.targetPlayer = getClosestActivePlayer(v.x + v.width*0.5,v.y + v.height*0.5)

	data.health = config.health

	data.state = STATE.NORMAL
	data.stateTimer = 0

	data.moveDirection = 0
	data.speedModifier = 1

	data.jumpAnticipationEdge = nil
	data.jumpAnticipationDone = false

	data.stompJumpCooldown = 0
	data.linkStabCooldown = 0

	data.skidEffectCooldown = 0

	data.invincibilityTimer = 0

	data.targetX = nil
	data.targetY = nil
	data.path = nil

	data.initialised = true
end


local function checkForNeighbourNode(node,direction)
	for _,edge in ipairs(node.edges) do
		if edge.jumpSpeedX == nil and (edge.destinationNode.gridX - node.gridX) == direction then
			return edge.destinationNode
		end
	end

	return nil
end

local function checkForOffsetNode(node,count,direction)
	while (count > 0) do
		local neighbourNode = checkForNeighbourNode(node,direction)

		if neighbourNode == nil then
			break
		end

		node = neighbourNode
		count = count - 1
	end

	return node
end

local function getPathJumpEdge(path)
	for i = #path,1,-1 do
		local edge = path[i]

		if edge.jumpSpeedX ~= nil then
			return edge
		end
	end

	return nil
end


local function calculateTimeToReachDistance(distance,currentSpeed,acceleration)
	--[[

		The distance, d, travelled after a given amount of time (t), starting
		with some existing speed (v) and accelerating at a constant rate (g), is given by:

			d = t^2 * g * 1/2 + t * v
			or t^2 * g * 1/2 + t * v - d = 0
		
		This can be rewritten as

			x^2 * a + x * b + c = 0
			with 	a = g/2
					b = v
					c = -d
					x = t
		
		We can therefore solve for t using the quadratic formula:

			x = (-b +- sqrt(b^2 - 4*a*c))/2a
			or
			t = (-v +- sqrt(v^2 + 2*g*d))/g
	
	]]

	return (math.sqrt(currentSpeed*currentSpeed + 2*acceleration*distance) - currentSpeed)/(acceleration*0.5)
end


local function canMakeSound(v)
	return battleCamera.isOnScreen(v.x,v.y,v.width,v.height)
end


local function isDucking(v)
	local config = NPC.config[v.id]
	local data = v.data

	if not v.collidesBlockBottom and config.jumpDuckEnabled then
		return true
	end

	if data.state >= STATE.LINK_STAB.CROUCH.ACTIVE and data.state <= STATE.LINK_STAB.CROUCH.LAG then
		return true
	end

	return false
end

local function getLinkStabHitbox(v)
	local config = NPC.config[v.id]

	local swordCollider = Colliders.Box(0,v.y + v.height - config.linkStabHitboxHeight + config.linkStabHitboxOffsetY,config.linkStabHitboxWidth,config.linkStabHitboxHeight)

	if v.direction == DIR_RIGHT then
		swordCollider.x = v.x + v.width + config.linkStabHitboxOffsetX
	else
		swordCollider.x = v.x - swordCollider.width - config.linkStabHitboxOffsetX
	end

	return swordCollider
end

local function getLinkCrouchStabHitbox(v)
	local config = NPC.config[v.id]

	local swordCollider = Colliders.Box(0,v.y + v.height - config.linkCrouchStabHitboxHeight + config.linkCrouchStabHitboxOffsetY,config.linkCrouchStabHitboxWidth,config.linkCrouchStabHitboxHeight)

	if v.direction == DIR_RIGHT then
		swordCollider.x = v.x + v.width + config.linkCrouchStabHitboxOffsetX
	else
		swordCollider.x = v.x - swordCollider.width - config.linkCrouchStabHitboxOffsetX
	end

	return swordCollider
end

local function getShieldHitbox(v)
	local config = NPC.config[v.id]
	local data = v.data

	if not config.linkShieldEnabled then
		return nil
	end

	if data.state >= STATE.LINK_STAB.NORMAL.ANTICIPATION and data.state <= STATE.LINK_STAB.RECOIL then
		return nil
	end

	local shieldCollider = Colliders.Box(
		v.x + (v.width - config.linkShieldHitboxWidth)*0.5 + config.linkShieldHitboxOffsetX*v.direction,
		v.y + v.height - config.linkShieldHitboxHeight + config.linkShieldHitboxOffsetY,
		config.linkShieldHitboxWidth,
		config.linkShieldHitboxHeight
	)

	if isDucking(v) then
		shieldCollider.y = shieldCollider.y + config.linkShieldHitboxDuckOffset
	end

	return shieldCollider
end

local function getPlayerShieldHitbox(p)
	if p.character ~= CHARACTER_LINK then
		return nil
	end

	if p:mem(0x14,FIELD_WORD) ~= 0 then
		return nil
	end

	local shieldCollider = Colliders.Box(
		p.x + (p.width - playerShieldHitboxWidth)*0.5 + playerShieldHitboxOffsetX*p.direction,
		p.y + p.height - playerShieldHitboxHeight + playerShieldHitboxOffsetY,
		playerShieldHitboxWidth,
		playerShieldHitboxHeight
	)

	if p.isDucking then
		shieldCollider.y = shieldCollider.y + playerShieldHitboxDuckOffset
	end

	return shieldCollider
end


local function checkForPlayersToStab(v)
	local config = NPC.config[v.id]
	local data = v.data

	local stabCollider = getLinkStabHitbox(v)
	local crouchStabCollider = getLinkCrouchStabHitbox(v)

	local minX = math.min(stabCollider.x,crouchStabCollider.x)
	local minY = math.min(stabCollider.y,crouchStabCollider.y)
	local maxX = math.max(stabCollider.x + stabCollider.width ,crouchStabCollider.x + crouchStabCollider.width )
	local maxY = math.max(stabCollider.y + stabCollider.height,crouchStabCollider.y + crouchStabCollider.height)

	--getPlayerShieldHitbox(player):draw()
	--getShieldHitbox(v):draw()

	for _,p in ipairs(Player.getIntersecting(minX,minY,maxX,maxY)) do
		if p.forcedState == FORCEDSTATE_NONE and p.invincibilityTimer == 0 then
			if stabCollider:collide(p) then
				data.state = STATE.LINK_STAB.NORMAL.ANTICIPATION
				data.stateTimer = 0

				data.linkStabCooldown = config.linkStabCooldown

				break
			elseif crouchStabCollider:collide(p) then
				data.state = STATE.LINK_STAB.CROUCH.ACTIVE
				data.stateTimer = 0

				data.linkStabCooldown = config.linkStabCooldown

				if canMakeSound(v) then
					SFX.play(config.linkStabSound,config.soundVolume)
				end

				break
			end
		end
	end
end

local function checkStabHarm(v,collider)
	local config = NPC.config[v.id]
	local data = v.data

	--collider:draw(Color.green)

	-- Hit players
	for _,p in ipairs(Player.getIntersecting(collider.x,collider.y,collider.x + collider.width,collider.y + collider.height)) do
		if p.forcedState == FORCEDSTATE_NONE and p.invincibilityTimer == 0 and collider:collide(p) then
			local shieldHitbox = getPlayerShieldHitbox(p)

			if shieldHitbox ~= nil and shieldHitbox:collide(collider) then
				if data.stateTimer >= 4 then
					data.state = STATE.LINK_STAB.RECOIL
					data.stateTimer = 0

					v.speedX = config.linkStabRecoilSpeed*v.direction

					if canMakeSound(v) then
						SFX.play(85)
					end
				end

				return
			end

			p:harm()
		end
	end

	-- Hit other NPCs
	for _,npc in NPC.iterateIntersecting(collider.x,collider.y,collider.x + collider.width,collider.y + collider.height) do
		if npc ~= v and collider:collide(npc) then
			local shieldHitbox = (fauxPlayer.idMap[npc.id] and getShieldHitbox(npc)) or nil

			if shieldHitbox ~= nil and shieldHitbox:collide(collider) then
				if data.stateTimer >= 4 then
					data.state = STATE.LINK_STAB.RECOIL
					data.stateTimer = 0

					v.speedX = config.linkStabRecoilSpeed*v.direction

					if canMakeSound(v) then
						SFX.play(85)
					end
				end

				return
			end

			npc:harm(HARM_TYPE_SWORD)
		end
	end
end


local function mainBehaviour(v)
	local config = NPC.config[v.id]
	local data = v.data

	data.moveDirection = 0

	data.stateTimer = data.stateTimer + 1

	-- Link stabbing
	if config.linkStabEnabled then
		if data.state == STATE.NORMAL then
			data.linkStabCooldown = math.max(0,data.linkStabCooldown - 1)

			if data.invincibilityTimer == 0 and data.linkStabCooldown == 0 then
				checkForPlayersToStab(v)
			end
		end

		if data.state >= STATE.LINK_STAB.NORMAL.ANTICIPATION and data.state <= STATE.LINK_STAB.NORMAL.LAG then
			if data.state == STATE.LINK_STAB.NORMAL.ANTICIPATION then
				if data.stateTimer >= config.linkStabAnticipationTime then
					data.state = STATE.LINK_STAB.NORMAL.ACTIVE
					data.stateTimer = 0

					if canMakeSound(v) then
						SFX.play(config.linkStabSound,config.soundVolume)
					end
				end
			elseif data.state == STATE.LINK_STAB.NORMAL.ACTIVE then
				checkStabHarm(v,getLinkStabHitbox(v))

				if data.stateTimer >= config.linkStabActiveTime then
					data.state = (v.collidesBlockBottom and STATE.LINK_STAB.NORMAL.LAG) or STATE.NORMAL
					data.stateTimer = 0
				end
			else
				if data.stateTimer >= config.linkStabLagTime or not v.collidesBlockBottom then
					data.state = STATE.NORMAL
					data.stateTimer = 0
				end
			end

			if v.collidesBlockBottom and data.state ~= STATE.LINK_STAB.RECOIL then
				v.speedX = 0
			end

			return
		end

		if data.state >= STATE.LINK_STAB.CROUCH.ACTIVE and data.state <= STATE.LINK_STAB.CROUCH.LAG then
			if data.state == STATE.LINK_STAB.CROUCH.ACTIVE then
				checkStabHarm(v,getLinkCrouchStabHitbox(v))

				if data.stateTimer >= config.linkCrouchStabActiveTime then
					data.state = (v.collidesBlockBottom and STATE.LINK_STAB.CROUCH.LAG) or STATE.NORMAL
					data.stateTimer = 0
				end
			else
				if data.stateTimer >= config.linkCrouchStabLagTime or not v.collidesBlockBottom then
					data.state = STATE.NORMAL
					data.stateTimer = 0
				end
			end

			if v.collidesBlockBottom and data.state ~= STATE.LINK_STAB.RECOIL then
				v.speedX = 0
			end

			return
		end

		if data.state == STATE.LINK_STAB.RECOIL then
			if data.stateTimer >= config.linkStabRecoilTime then
				data.state = STATE.NORMAL
				data.stateTimer = 0
			end

			local deceleration = (v.collidesBlockBottom and config.linkStabRecoilDecelerationGrounded) or config.linkStabRecoilDecelerationAir

			if v.speedX > 0 then
				v.speedX = math.max(0,v.speedX - deceleration)
			elseif v.speedX < 0 then
				v.speedX = math.min(0,v.speedX + deceleration)
			end

			return
		end
	end

	-- Harm knockback
	if data.state == STATE.HARM_KNOCKBACK then
		if v.collidesBlockBottom then
			data.state = STATE.NORMAL
			data.stateTimer = 0
		end
	end

	-- Pathing
	local forceDirection
	local targetX
	local targetSpeedX = 0
	local noStandingStill = false

	if data.path ~= nil then
		local currentEdge = data.path[#data.path]
		local jumpEdge = getPathJumpEdge(data.path)

		if v.collidesBlockBottom then
			data.stompJumpCooldown = math.max(0,data.stompJumpCooldown - 1)
			data.jumpTargetX = nil

			if jumpEdge == nil then
				targetX = data.targetX
				targetSpeedX = data.targetPlayer.speedX

				-- Try to jump on the player's head
				local difference = targetX - (v.x + v.width*0.5)
				local direction = math.sign(difference)

				if config.stompJumpEnabled and data.stompJumpCooldown == 0 and math.sign(v.speedX) == direction then
					local timeA = calculateTimeToReachDistance(math.abs(difference),math.abs(v.speedX),0.1)

					local timeB = calculateTimeToReachDistance(math.abs(targetX + data.targetPlayer.speedX*timeA - (v.x + v.width*0.5)),math.abs(v.speedX),0.1)
					local speedY = (data.targetPlayer.y - (v.y + v.height))/timeB - config.gravity*timeB*0.5

					if speedY > config.stompJumpMinSpeedY and speedY < config.stompJumpMaxSpeedY then
						data.stompJumpCooldown = config.stompJumpCooldown
						data.linkStabCooldown = config.linkStabJumpCooldown
						v.speedY = speedY

						if canMakeSound(v) then
							SFX.play(config.jumpSound,config.soundVolume)
						end
					end
				end
			elseif jumpEdge == data.jumpAnticipationEdge and not data.jumpAnticipationDone then
				local jumpDirection = math.sign(jumpEdge.jumpSpeedX)

				local necessarySpace = math.abs(jumpEdge.jumpSpeedX)*2/config.runAcceleration
				local necessaryGridWidth = math.ceil(necessarySpace/tilemap.gridSize)
				local targetNode = checkForOffsetNode(jumpEdge.originNode,necessaryGridWidth,-jumpDirection)

				--targetX = targetNode.x - jumpDirection*tilemap.gridSize*0.5
				targetX = math.max(jumpEdge.originNode.x*jumpDirection - necessarySpace,targetNode.x*jumpDirection)*jumpDirection
				targetSpeedX = 0---jumpEdge.jumpSpeedX

				noStandingStill = true

				if (targetX - (v.x + v.width*0.5))*jumpDirection > 0 then
					data.jumpAnticipationDone = true
				end
			else
				local jumpDirection = math.sign(jumpEdge.jumpSpeedX)

				targetX = jumpEdge.originNode.x-- + jumpDirection*tilemap.gridSize*0.5
				targetSpeedX = jumpEdge.jumpSpeedX

				local jumpPointDistance = (targetX - (v.x + v.width*0.5))*jumpDirection

				local acceleration = (math.abs(v.speedX) < config.walkSpeed and config.walkAcceleration) or config.runAcceleration
				local reachTargetTime = calculateTimeToReachDistance(math.abs(targetX - (v.x + v.width*0.5)),math.abs(v.speedX),acceleration*data.speedModifier)

				if (v.speedX*jumpDirection + acceleration*data.speedModifier*reachTargetTime) < jumpEdge.jumpSpeedX*jumpDirection*0.75
				and not (jumpEdge == data.jumpAnticipationEdge and data.jumpAnticipationDone)
				then
					data.jumpAnticipationEdge = jumpEdge
					data.jumpAnticipationDone = false
				else
					if jumpPointDistance <= 0 and jumpPointDistance >= -16 then
						local targetY = jumpEdge.destinationNode.y
						if (v.y + v.height) > targetY then
							targetY = targetY - ((jumpEdge.destinationNode.onSemisolid and 8) or 16)
						end

						local time = math.max(1,math.abs(((v.x + v.width*0.5) - jumpEdge.destinationNode.x)/jumpEdge.jumpSpeedX))

						v.speedY = (targetY - (v.y + v.height))/time - config.gravity*time*0.5

						data.jumpTargetX = jumpEdge.destinationNode.x
						data.jumpTargetSpeedX = jumpEdge.jumpSpeedX

						data.stompJumpCooldown = config.stompJumpCooldown
						data.linkStabCooldown = config.linkStabJumpCooldown

						if canMakeSound(v) then
							SFX.play(config.jumpSound,config.soundVolume)
						end
					end

					if math.sign(v.speedX) == jumpDirection then
						forceDirection = jumpDirection
					end
				end
			end
		else
			if data.jumpTargetX ~= nil then
				data.moveDirection = math.sign(data.jumpTargetSpeedX - v.speedX)
				forceDirection = math.sign(data.jumpTargetSpeedX)

				--[[targetX = data.jumpTargetX
				targetSpeedX = data.jumpTargetSpeedX
				forceDirection = math.sign(data.jumpTargetSpeedX)]]
			elseif jumpEdge ~= nil then
				targetX = jumpEdge.originNode.x
			else
				targetX = data.targetX
				targetSpeedX = data.targetPlayer.speedX
			end

			data.jumpAnticipationEdge = nil
		end
	end

	if targetX ~= nil then
		local difference = targetX - (v.x + v.width*0.5)

		if targetSpeedX == 0 and math.abs(difference) < 4 and math.abs(v.speedX) < 1 and not noStandingStill then
			data.moveDirection = 0
		elseif math.sign(difference) == math.sign(v.speedX) and math.sign(targetSpeedX) ~= -math.sign(v.speedX) and math.abs(v.speedX) > 1 then
			--local stopTime = math.max(0,math.abs(targetSpeedX - v.speedX))/config.idleDecelerationGrounded
			local turnTime = math.max(0,math.abs(targetSpeedX - v.speedX))/(config.turnAroundAcceleration*data.speedModifier)

			-- Calculate the amount of time it will take to reach the target position, given acceleration and the current speed
			local acceleration = (math.abs(v.speedX) < config.walkSpeed and config.walkAcceleration) or config.runAcceleration
			local reachTargetTime = calculateTimeToReachDistance(math.abs(difference),math.abs(v.speedX),acceleration*data.speedModifier)

			if reachTargetTime > turnTime then
				data.moveDirection = math.sign(difference)
			else
				data.moveDirection = -math.sign(difference)
			end
		else
			data.moveDirection = math.sign(difference)
		end

		--[[Graphics.drawCircle{
			color = Color.green,
			sceneCoords = true,
			x = targetX,
			y = data.targetY,
			radius = 4,
		}]]
	end

	-- Acceleration/deceleration
	if data.moveDirection ~= 0 then
		if v.speedX*data.moveDirection < config.runSpeed*data.speedModifier then
			local acceleration =
				(v.speedX*data.moveDirection < 0 and config.turnAroundAcceleration)
				or (v.speedX*data.moveDirection < config.walkSpeed*data.speedModifier and config.walkAcceleration)
				or config.runAcceleration

			v.speedX = math.min(config.runSpeed*data.speedModifier,v.speedX*data.moveDirection + acceleration*data.speedModifier)*data.moveDirection
		end

		v.direction = data.moveDirection
	else
		local deceleration = (v.collidesBlockBottom and config.idleDecelerationGrounded) or config.idleDecelerationAir

		if v.speedX > 0 then
			v.speedX = math.max(0,v.speedX - deceleration*data.speedModifier)
		elseif v.speedX < 0 then
			v.speedX = math.min(0,v.speedX + deceleration*data.speedModifier)
		end

		if math.abs(v.speedX) < config.idleDecelerationStopThreshold then
			v.speedX = 0
		end
	end

	if forceDirection ~= nil then
		v.direction = forceDirection
	end
end


local function findAnimation(v)
	local config = NPC.config[v.id]
	local data = v.data

	if data.state >= STATE.LINK_STAB.NORMAL.ANTICIPATION and data.state <= STATE.LINK_STAB.NORMAL.LAG then
		if data.state == STATE.LINK_STAB.NORMAL.ACTIVE then
			return "stab"
		end

		return "stabPrepare"
	end

	if data.state == STATE.LINK_STAB.CROUCH.ACTIVE then
		return "crouchStab"
	elseif data.state == STATE.LINK_STAB.CROUCH.LAG then
		return "crouch"
	end

	if data.state == STATE.LINK_STAB.RECOIL then
		return "stabPrepare"
	end

	if data.state == STATE.HARM_KNOCKBACK then
		return "hurt"
	end

	if v.collidesBlockBottom then
		if config.skidEnabled and math.sign(v.speedX) == -data.moveDirection and v.direction == data.moveDirection then
			return "skid"
		end

		if v.speedX ~= 0 then
			return "walk",math.abs(v.speedX)
		end

		return "idle"
	end

	if v.speedY < 0 then
		return "jump"
	else
		return "fall"
	end
end


local function handlePlayerInteraction(v,p)
	-- Designed to be very similar (though not the same) as regular player-to-player interaction
	if p.forcedState ~= FORCEDSTATE_NONE or p.invincibilityTimer ~= 0 then
		return
	end

	local config = NPC.config[v.id]
	local data = v.data

	if (v.y + v.height - v.speedY - 4) <= (p.y - p.speedY) then
		v.y = p.y - v.height
		v.speedY = p.speedY + config.stompHitSpeed

		p:harm()

		Effect.spawn(75,v.x + v.width*0.5 - 16,v.y + v.height - 16)

		if canMakeSound(v) or onlinePlayPlayers.canMakeSound(p) then
			SFX.play(config.stompSound,config.soundVolume)
		end

		return
	end

	--[[if (p.y + p.height - p.speedY - 4) <= (v.y - v.speedY) then
		p:mem(0x11C,FIELD_WORD,Defines.jumpheight)
        p.speedY = Defines.jumpspeed
		p.y = v.y - p.height - 0.1

		v:harm(HARM_TYPE_JUMP)

		Effect.spawn(75,p.x + p.width*0.5 - 16,p.y + p.height - 16)

		if canMakeSound(v) or onlinePlayPlayers.canMakeSound(p) then
			SFX.play(2)
		end

		return
	end]]

	if (v.x + v.width - v.speedX - 4) <= (p.x - p.speedX) or (v.x - v.speedX + 4) >= (p.x + p.width - p.speedX) then
		p:mem(0x136,FIELD_BOOL,true)
		p.speedX,v.speedX = v.speedX,p.speedX
	end
end


function fauxPlayer.onTickNPC(v)
	if Defines.levelFreeze then return end

	local data = v.data

	if v.despawnTimer <= 0 then
		data.initialised = false
		return
	end

	local config = NPC.config[v.id]

	if not data.initialised then
		fauxPlayer.initialise(v)
	end

	data.targetPlayer = getClosestActivePlayer(v.x + v.width*0.5,v.y + v.height*0.5,data.targetPlayer)

	data.targetX = data.targetPlayer.x + data.targetPlayer.width*0.5
	data.targetY = data.targetPlayer.y + data.targetPlayer.height*0.5

	--[[local startGridX,startGridY = tilemap.convertWorldPositionToGrid(v.x + v.width*0.5,v.y + v.height - 8)
	local startNode = pathfinding.getNodeFromGrid(startGridX,startGridY)

	local targetGridX,targetGridY = tilemap.convertWorldPositionToGrid(data.targetX,data.targetY)
	local targetNode = pathfinding.getNodeFromGrid(targetGridX,targetGridY)]]

	local startNode = pathfinding.getNearbyNode(v.x + v.width*0.5,v.y + v.height*0.5)
	local targetNode = pathfinding.getNearbyNode(data.targetX,data.targetY)

	if startNode ~= nil and targetNode ~= nil then
		data.path = pathfinding.findPath(startNode,targetNode)
	end

	-- Get speed modifier from the slope being stood on
	local slopeBlockIdx = v:mem(0x22,FIELD_WORD)
	
	data.speedModifier = 1

    if slopeBlockIdx > 0 and slopeBlockIdx <= Block.count() then
        local slopeBlock = Block(slopeBlockIdx)
        local slopeBlockConfig = Block.config[slopeBlock.id]

        if slopeBlockConfig ~= nil then
            if math.sign(v.speedX) == -slopeBlockConfig.floorslope then -- waking uphill
                data.speedModifier = data.speedModifier*(1 - slopeBlock.height/slopeBlock.width*0.5)
            else -- walking downhill
                data.speedModifier = data.speedModifier*(1 + slopeBlock.height/slopeBlock.width*0.25)
            end
        end
    end

	mainBehaviour(v)

	v.speedY = v.speedY + config.gravity

	-- Skidding
	if config.skidEnabled and v.collidesBlockBottom and math.sign(v.speedX) == -data.moveDirection and v.direction == data.moveDirection then
		if canMakeSound(v) then
			SFX.play(config.skidSound,config.soundVolume,1,8)
		end

		if data.skidEffectCooldown == 0 then
			local e = Effect.spawn(config.skidEffectID,v.x + v.width*0.5 + config.skidEffectOffsetX*v.direction,v.y + v.height + config.skidEffectOffsetY)

			e.x = e.x - e.width*0.5

			data.skidEffectCooldown = RNG.randomInt(config.skidEffectTimeMin,config.skidEffectTimeMax)
		end
	end

	data.skidEffectCooldown = math.max(0,data.skidEffectCooldown - 1)

	-- Stomping
	if data.invincibilityTimer == 0 then
		for _,p in ipairs(Player.getIntersecting(v.x,v.y,v.x + v.width,v.y + v.height)) do
			handlePlayerInteraction(v,p)
		end
	end

	-- Animation logic
	local animationName,animationSpeed = findAnimation(v)

	data.animator:setAnimation(animationName,animationSpeed)
	data.animator:update()

	if v:mem(0x120,FIELD_BOOL) then
		if v.collidesBlockLeft or v.collidesBlockRight then
			v.speedX = 0
		end

		v:mem(0x120,FIELD_BOOL,false)
	end

	data.invincibilityTimer = math.max(0,data.invincibilityTimer - 1)
end

function fauxPlayer.onDrawNPC(v)
	if v.despawnTimer <= 0 then
		return
	end

	local config = NPC.config[v.id]
	local data = v.data

	if not data.initialised then
		fauxPlayer.initialise(v)
	end

	if data.path ~= nil and pathfinding.debug then
		for _,edge in ipairs(data.path) do
			if edge.jumpArc ~= nil then
				for i = 1,(#edge.jumpArc - 1) do
                    local pointA = edge.jumpArc[i]
                    local pointB = edge.jumpArc[i + 1]

                    Graphics.drawLine{
                        color = ((i%2 == 0 and Color.orange) or Color.red),
                        sceneCoords = true,

                        start = pointA,
                        stop = pointB,
                    }
                end
			else
				Graphics.drawLine{
					color = Color.yellow,
					sceneCoords = true,

					x1 = edge.originNode.x,
					y1 = edge.originNode.y,
					x2 = edge.destinationNode.x,
					y2 = edge.destinationNode.y,
				}
			end
		end
	end

	v.animationFrame = npcutils.getFrameByFramestyle(v,{frame = data.animator.frame.y - 1})

	if data.invincibilityTimer > 0 and data.invincibilityTimer%6 < 3 then
		npcutils.hideNPC(v)
	end
end


local function getLinkPlayerSwordHitbox(p)
    -- Based on: https://github.com/smbx/smbx-legacy-source/blob/master/modPlayer.bas#L5030
    if p.character ~= CHARACTER_LINK then
        return nil
    end

    -- Crouching stab
    if p:mem(0x14,FIELD_WORD) > 0 and p.isDucking then
        local swordCollider = Colliders.Box(0,p.y + p.height - 22,38,8)

        if p.direction == DIR_RIGHT then
            swordCollider.x = p.x + p.width
        else
            swordCollider.x = p.x - swordCollider.width
        end

        return swordCollider
    end

    -- Horizontal stab
    if p:mem(0x14,FIELD_WORD) > 0 then
        local swordCollider = Colliders.Box(0,p.y + p.height - 42,38,6)

        if p.direction == DIR_RIGHT then
            swordCollider.x = p.x + p.width
        else
            swordCollider.x = p.x - swordCollider.width
        end

        return swordCollider
    end

	return nil
end


function fauxPlayer.onNPCHarm(eventObj,v,reason,culprit)
	if not fauxPlayer.idMap[v.id] then
		return
	end

	local config = NPC.config[v.id]
	local data = v.data

	if not data.initialised then
		fauxPlayer.initialise(v)
	end

	if reason == HARM_TYPE_JUMP then
		SFX.play(2)
	end

	if data.invincibilityTimer > 0 then
		eventObj.cancelled = true
		return
	end

	if reason == HARM_TYPE_SWORD then
		local shieldCollider = getShieldHitbox(v)

		if shieldCollider ~= nil then
			for _,p in ipairs(Player.get()) do
				local swordCollider = getLinkPlayerSwordHitbox(p)

				if swordCollider ~= nil and swordCollider:collide(shieldCollider) then
					p:mem(0x14,FIELD_WORD,-11)

					if onlinePlayPlayers.canMakeSound(p) then
						SFX.play(85)
					end

					eventObj.cancelled = true
					return
				end
			end
		end
	end

	data.health = math.max(0,data.health - 1)

	if data.health > 0 then
		data.invincibilityTimer = config.harmInvincibilityTime

		if canMakeSound(v) then
			SFX.play(config.hurtSound)
		end

		if config.harmKnockbackEnabled then
			data.state = STATE.HARM_KNOCKBACK
			data.stateTimer = 0

			v.speedX = config.harmKnockbackSpeedX*v.direction
			v.speedY = config.harmKnockbackSpeedY
			v:mem(0x0A,FIELD_WORD,0)
		end

		eventObj.cancelled = true
	end
end

function fauxPlayer.onPostNPCKill(v,reason)
	if not fauxPlayer.idMap[v.id] then
		return
	end

	local config = NPC.config[v.id]
	local data = v.data

	if reason ~= HARM_TYPE_VANISH and canMakeSound(v) then
		SFX.play(config.deathSound)
	end

	if v.killFlag == HARM_TYPE_SWORD then
		v.killFlag = HARM_TYPE_NPC
	end
end


function fauxPlayer.onInitAPI()
	registerEvent(fauxPlayer,"onNPCHarm")
	registerEvent(fauxPlayer,"onPostNPCKill")
end


return fauxPlayer