diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index f77c2434..0de6a44e 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -124,6 +124,7 @@ public class AnimationComponent: Component { var currentAnimation: AnimationClip? public var animationsFilenames: [URL] = [] var pause: Bool = false + var playbackSpeed: Float = 1.0 var currentTime: Float = 0.0 public required init() {} diff --git a/Sources/UntoldEngine/Scenes/Builder/NodeCore/Node+Animations.swift b/Sources/UntoldEngine/Scenes/Builder/NodeCore/Node+Animations.swift index e83bf9a8..8153b72e 100644 --- a/Sources/UntoldEngine/Scenes/Builder/NodeCore/Node+Animations.swift +++ b/Sources/UntoldEngine/Scenes/Builder/NodeCore/Node+Animations.swift @@ -10,6 +10,7 @@ public protocol NodeAnimations: NodeProtocol { func setAnimations(resource: String, name: String) -> Self func changeAnimation(name: String, withPause pause: Bool) -> Self + func setAnimationPlaybackSpeed(speed: Float) -> Self } public extension NodeAnimations { @@ -22,4 +23,9 @@ public extension NodeAnimations { UntoldEngine.changeAnimation(entityId: entityID, name: name, withPause: pause) return self } + + func setAnimationPlaybackSpeed(speed: Float) -> Self { + UntoldEngine.setAnimationPlaybackSpeed(entityId: entityID, speed: speed) + return self + } } diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index 255e2ba5..841e1c33 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -151,6 +151,7 @@ struct EntityData: Codable { var axisOfRotations: simd_float3 = .zero var scale: simd_float3 = .one var animations: [URL] = [] + var animationPlaybackSpeed: Float = 1.0 var mass: Float = .init(1.0) var lightData: LightData? = nil var cameraData: CameraData? = nil @@ -263,6 +264,7 @@ public func serializeScene() -> SceneData { // Animation properties if let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) { entityData.animations = animationComponent.animationsFilenames + entityData.animationPlaybackSpeed = animationComponent.playbackSpeed } entityData.hasAnimationComponent = hasComponent(entityId: entityId, componentType: AnimationComponent.self) @@ -648,6 +650,7 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM } if let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) { animationComponent.animationsFilenames = sceneDataEntity.animations + animationComponent.playbackSpeed = sceneDataEntity.animationPlaybackSpeed } } case .asyncDefault: @@ -667,6 +670,7 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM } if let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) { animationComponent.animationsFilenames = sceneDataEntity.animations + animationComponent.playbackSpeed = sceneDataEntity.animationPlaybackSpeed } } } else { @@ -693,6 +697,7 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM } if let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) { animationComponent.animationsFilenames = sceneDataEntity.animations + animationComponent.playbackSpeed = sceneDataEntity.animationPlaybackSpeed } } case .asyncDefault: @@ -713,6 +718,7 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM } if let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) { animationComponent.animationsFilenames = sceneDataEntity.animations + animationComponent.playbackSpeed = sceneDataEntity.animationPlaybackSpeed } } } else { diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index e4256bc2..32387937 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -63,7 +63,7 @@ private func updateAnimationSystem(deltaTime: Float) { continue } - animationComponent.currentTime += deltaTime + animationComponent.currentTime += deltaTime * animationComponent.playbackSpeed guard let animationClip = animationComponent.currentAnimation else { return } @@ -114,6 +114,24 @@ public func changeAnimation(entityId: EntityID, name: String, withPause: Bool = animationComponent.pause = withPause } +public func setAnimationPlaybackSpeed(entityId: EntityID, speed: Float) { + guard let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) else { + handleError(.noAnimationComponent, entityId) + return + } + + animationComponent.playbackSpeed = max(0.0, speed) +} + +public func getAnimationPlaybackSpeed(entityId: EntityID) -> Float { + guard let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) else { + handleError(.noAnimationComponent, entityId) + return 1.0 + } + + return animationComponent.playbackSpeed +} + public func getAllAnimationClips(entityId: EntityID) -> [String] { guard let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) else { return [] diff --git a/Sources/UntoldEngine/Systems/CameraSystem.swift b/Sources/UntoldEngine/Systems/CameraSystem.swift index 35002675..f43ca35b 100644 --- a/Sources/UntoldEngine/Systems/CameraSystem.swift +++ b/Sources/UntoldEngine/Systems/CameraSystem.swift @@ -387,6 +387,72 @@ public func cameraFollowLocal(entityId: EntityID, updateCameraViewMatrix(entityId: entityId) } +public func cameraFollowDeadZone(entityId: EntityID, + targetEntity: EntityID, + offset: simd_float3 = simd_float3(0, 0, 0), + deadZoneExtents: simd_float3, + smoothFactor: Float = 0.0, + deltaTime: Float = 0.0) +{ + guard let cameraComponent = scene.get(component: CameraComponent.self, for: entityId) else { + return + } + guard let targetTransform = scene.get(component: LocalTransformComponent.self, for: targetEntity) else { + return + } + + let cameraPosition = cameraComponent.localPosition + let targetPosition = targetTransform.position + offset + let toTarget = targetPosition - cameraPosition + + let xAxis = rightDirectionVector(from: cameraComponent.rotation) + let yAxis = upDirectionVector(from: cameraComponent.rotation) + let zAxis = forwardDirectionVector(from: cameraComponent.rotation) + + let localTarget = simd_float3( + simd_dot(toTarget, xAxis), + simd_dot(toTarget, yAxis), + simd_dot(toTarget, zAxis) + ) + + let extents = simd_abs(deadZoneExtents) + var correctionLocal = simd_float3(0, 0, 0) + + if localTarget.x > extents.x { + correctionLocal.x = localTarget.x - extents.x + } else if localTarget.x < -extents.x { + correctionLocal.x = localTarget.x + extents.x + } + + if localTarget.y > extents.y { + correctionLocal.y = localTarget.y - extents.y + } else if localTarget.y < -extents.y { + correctionLocal.y = localTarget.y + extents.y + } + + if localTarget.z > extents.z { + correctionLocal.z = localTarget.z - extents.z + } else if localTarget.z < -extents.z { + correctionLocal.z = localTarget.z + extents.z + } + + if length(correctionLocal) <= 0.0001 { + return + } + + let correctionWorld = xAxis * correctionLocal.x + yAxis * correctionLocal.y + zAxis * correctionLocal.z + let desiredPosition = cameraPosition + correctionWorld + + if smoothFactor > 0, deltaTime > 0 { + let t = min(smoothFactor * deltaTime, 1.0) + cameraComponent.localPosition = cameraPosition + (desiredPosition - cameraPosition) * t + } else { + cameraComponent.localPosition = desiredPosition + } + + updateCameraViewMatrix(entityId: entityId) +} + public func cameraOrbitTarget(entityId: EntityID, centerEntity: EntityID, radius: Float, diff --git a/Sources/UntoldEngine/Systems/InputSystem+Keyboard.swift b/Sources/UntoldEngine/Systems/InputSystem+Keyboard.swift index f64a7ca8..df75005c 100644 --- a/Sources/UntoldEngine/Systems/InputSystem+Keyboard.swift +++ b/Sources/UntoldEngine/Systems/InputSystem+Keyboard.swift @@ -13,6 +13,7 @@ public struct KeyState { public var wPressed = false, aPressed = false, sPressed = false, dPressed = false + public var jPressed = false, kPressed = false, lPressed = false public var qPressed = false, ePressed = false public var spacePressed = false, shiftPressed = false, ctrlPressed = false public var altPressed = false @@ -82,6 +83,9 @@ public extension InputSystem { case kVK_ANSI_Q: keyState.qPressed = true case kVK_ANSI_E: keyState.ePressed = true case kVK_ANSI_Space: keyState.spacePressed = true + case kVK_ANSI_J: keyState.jPressed = true + case kVK_ANSI_K: keyState.kPressed = true + case kVK_ANSI_L: keyState.lPressed = true // case kVK_ANSI_G: print("G pressed") default: break } @@ -97,6 +101,9 @@ public extension InputSystem { case kVK_ANSI_S: keyState.sPressed = false case kVK_ANSI_Q: keyState.qPressed = false case kVK_ANSI_E: keyState.ePressed = false + case kVK_ANSI_J: keyState.jPressed = false + case kVK_ANSI_K: keyState.kPressed = false + case kVK_ANSI_L: keyState.lPressed = false case kVK_ANSI_Space: keyState.spacePressed = false // case kVK_ANSI_G: print("G released") default: break diff --git a/Sources/UntoldEngine/Systems/InputSystem.swift b/Sources/UntoldEngine/Systems/InputSystem.swift index 9f00fa09..3bae70f5 100644 --- a/Sources/UntoldEngine/Systems/InputSystem.swift +++ b/Sources/UntoldEngine/Systems/InputSystem.swift @@ -34,7 +34,7 @@ public final class InputSystem { public let kVK_ANSI_Q: UInt16 = 12, kVK_ANSI_E: UInt16 = 14 public let kVK_ANSI_1: UInt16 = 18, kVK_ANSI_2: UInt16 = 19 public let kVK_ANSI_G: UInt16 = 5, kVK_ANSI_X: UInt16 = 7, kVK_ANSI_Y: UInt16 = 16, kVK_ANSI_Z: UInt16 = 6 - public let kVK_ANSI_Space: UInt16 = 49 + public let kVK_ANSI_Space: UInt16 = 49, kVK_ANSI_J: UInt16 = 38, kVK_ANSI_K: UInt16 = 40 public var keyState = KeyState() public var gamePadState = GamePadState() diff --git a/Sources/UntoldEngine/Systems/SteeringSystem.swift b/Sources/UntoldEngine/Systems/SteeringSystem.swift index bddf1a3b..2b6b8980 100644 --- a/Sources/UntoldEngine/Systems/SteeringSystem.swift +++ b/Sources/UntoldEngine/Systems/SteeringSystem.swift @@ -20,6 +20,14 @@ func setWaypointIndex(for entityId: EntityID, index: Int) { entityWaypointIndices[entityId] = index } +private func safeNormalize(_ vector: simd_float3, fallback: simd_float3 = simd_float3(0.0, 0.0, 0.0)) -> simd_float3 { + let magnitude = length(vector) + guard magnitude > 0.0001 else { + return fallback + } + return vector / magnitude +} + public func getDistanceFromPath(for entityId: EntityID, path: [simd_float3]) -> Float? { // Return nil if the path is empty guard !path.isEmpty else { @@ -35,13 +43,18 @@ public func getDistanceFromPath(for entityId: EntityID, path: [simd_float3]) -> let startPoint = path[currentWaypointIndex] let endPoint = path[nextWaypointIndex] - // Compute the direction vector and normalize it - let direction = simd_normalize(endPoint - startPoint) + let segment = endPoint - startPoint + let segmentLength = length(segment) + guard segmentLength > 0.0001 else { + return distance(entityPosition, startPoint) + } + let direction = segment / segmentLength // Project the entity's position onto the line segment let offset = entityPosition - startPoint let projection = dot(offset, direction) - let closestPointOnPath = startPoint + projection * direction + let clampedProjection = max(0.0, min(segmentLength, projection)) + let closestPointOnPath = startPoint + clampedProjection * direction // Compute the distance from the entity's position to the closest point return distance(entityPosition, closestPointOnPath) @@ -61,7 +74,7 @@ func seek(entityId: EntityID, targetPosition: simd_float3, maxSpeed: Float) -> s let position = getLocalPosition(entityId: entityId) // calculate the desired velocity towards the target - let desiredVelocity = normalize(targetPosition - position) * maxSpeed + let desiredVelocity = safeNormalize(targetPosition - position) * maxSpeed guard let physicsComponent = scene.get(component: PhysicsComponents.self, for: entityId) else { handleError(.noPhysicsComponent, entityId) @@ -84,7 +97,7 @@ public func flee(entityId: EntityID, threatPosition: simd_float3, maxSpeed: Floa let position = getLocalPosition(entityId: entityId) // Calculate the desired velocity away from the threat - let desiredVelocity = normalize(position - threatPosition) * maxSpeed + let desiredVelocity = safeNormalize(position - threatPosition) * maxSpeed guard let physicsComponent = scene.get(component: PhysicsComponents.self, for: entityId) else { handleError(.noPhysicsComponent, entityId) @@ -109,10 +122,10 @@ public func arrive(entityId: EntityID, targetPosition: simd_float3, maxSpeed: Fl let distance = length(toTarget) // Adjust speed based on distance to target - let speed = min(maxSpeed, maxSpeed * (distance / slowingRadius)) + let speed = (slowingRadius > 0.0) ? min(maxSpeed, maxSpeed * (distance / slowingRadius)) : maxSpeed // Calculate the desired velocity - let desiredVelocity = normalize(toTarget) * speed + let desiredVelocity = safeNormalize(toTarget) * speed guard let physicsComponent = scene.get(component: PhysicsComponents.self, for: entityId) else { handleError(.noPhysicsComponent, entityId) @@ -123,7 +136,7 @@ public func arrive(entityId: EntityID, targetPosition: simd_float3, maxSpeed: Fl return desiredVelocity - physicsComponent.velocity } -public func pursuit(entityId: EntityID, targetEntity: EntityID, maxSpeed: Float) -> simd_float3 { +public func pursuit(entityId: EntityID, targetEntity: EntityID, maxSpeed: Float, targetOffset: simd_float3 = simd_float3(0.0, 0.0, 0.0)) -> simd_float3 { guard let physicsComponent = scene.get(component: PhysicsComponents.self, for: entityId) else { handleError(.noPhysicsComponent, entityId) return simd_float3(0.0, 0.0, 0.0) @@ -143,13 +156,16 @@ public func pursuit(entityId: EntityID, targetEntity: EntityID, maxSpeed: Float) } let position = getLocalPosition(entityId: entityId) - let targetPosition = getLocalPosition(entityId: targetEntity) + let targetPosition = getLocalPosition(entityId: targetEntity) + targetOffset // Estimate where the target entity will be based on its current velocity let toTarget = targetPosition - position - let relativeHeading = dot(normalize(physicsTargetComponent.velocity), normalize(physicsComponent.velocity)) + let relativeHeading = dot( + safeNormalize(physicsTargetComponent.velocity), + safeNormalize(physicsComponent.velocity) + ) - let predictionTime = (relativeHeading > 0.95) ? (length(toTarget) / maxSpeed) : 0.5 + let predictionTime = (maxSpeed > 0.0 && relativeHeading > 0.95) ? (length(toTarget) / maxSpeed) : 0.5 let futurePosition = targetPosition + physicsTargetComponent.velocity * predictionTime // Seek towards the predicted future position of target entity @@ -175,14 +191,14 @@ public func evade(entityId: EntityID, threatEntity: EntityID, maxSpeed: Float) - // Estimate where the threat will be based on its velocity let toThreat = threatPosition - position - let predictionTime = length(toThreat) / maxSpeed + let predictionTime = (maxSpeed > 0.0) ? (length(toThreat) / maxSpeed) : 0.0 let futureThreatPosition = threatPosition + physicsThreatComponent.velocity * predictionTime // Flee from the predicted future position of the threat return flee(entityId: entityId, threatPosition: futureThreatPosition, maxSpeed: maxSpeed) } -public func alignOrientation(entityId: EntityID, targetDirection _: simd_float3, deltaTime: Float, turnSpeed: Float) { +public func alignOrientation(entityId: EntityID, targetDirection: simd_float3, deltaTime: Float, turnSpeed: Float) { if gameMode == false { return } @@ -193,14 +209,18 @@ public func alignOrientation(entityId: EntityID, targetDirection _: simd_float3, // Retrieve the entity's current velocity let velocity = getVelocity(entityId: entityId) + var desiredDirection = targetDirection + if length(desiredDirection) <= 0.001 { + desiredDirection = velocity + } // Align the entity's orientation to its movement direction - if length(velocity) > 0.001 { // Avoid division by zero for stationary entities - let forwardDirection = normalize(velocity) // Forward direction based on movement + if length(desiredDirection) > 0.001 { // Avoid division by zero for stationary entities + let forwardDirection = safeNormalize(desiredDirection) // Forward direction based on target let upVector = simd_float3(0, 1, 0) // Assuming Y-up coordinate system // Calculate the right vector using cross product - let rightVector = normalize(cross(upVector, forwardDirection)) + let rightVector = safeNormalize(cross(upVector, forwardDirection)) // Recalculate the true up vector for orthogonality let correctedUpVector = cross(forwardDirection, rightVector) @@ -256,6 +276,10 @@ public func orbit(entityId: EntityID, centerPosition: simd_float3, radius: Float let relativePosition = currentPosition - centerPosition // Calculate angular velocity (speed around the orbit) + guard radius > 0.0, maxSpeed > 0.0 else { + return + } + let angularVelocity = maxSpeed / radius // radians per second // Increment angle based on angular velocity and deltaTime @@ -271,7 +295,7 @@ public func orbit(entityId: EntityID, centerPosition: simd_float3, radius: Float translateTo(entityId: entityId, position: newPosition) // Align the entity's orientation to face its tangential direction - let tangentialDirection = normalize(simd_float3(-sin(currentAngle), 0.0, cos(currentAngle))) + let tangentialDirection = safeNormalize(simd_float3(-sin(currentAngle), 0.0, cos(currentAngle))) alignOrientation(entityId: entityId, targetDirection: tangentialDirection, deltaTime: deltaTime, turnSpeed: turnSpeed) } @@ -308,7 +332,7 @@ public func steerSeek(entityId: EntityID, targetPosition: simd_float3, maxSpeed: // Align orientation to face the target let currentPosition = getPosition(entityId: entityId) - let targetDirection = normalize(targetPosition - currentPosition) + let targetDirection = safeNormalize(targetPosition - currentPosition) alignOrientation(entityId: entityId, targetDirection: targetDirection, deltaTime: deltaTime, turnSpeed: turnSpeed) } @@ -342,63 +366,65 @@ public func steerArrive(entityId: EntityID, targetPosition: simd_float3, maxSpee // Align orientation to face the target let currentPosition = getPosition(entityId: entityId) - let targetDirection = normalize(targetPosition - currentPosition) + let targetDirection = safeNormalize(targetPosition - currentPosition) alignOrientation(entityId: entityId, targetDirection: targetDirection, deltaTime: deltaTime, turnSpeed: turnSpeed) } -public func steerWithWASD(entityId: EntityID, maxSpeed: Float, deltaTime: Float, turnSpeed: Float = 1.0, weight: Float = 1.0) { - if gameMode == false { - return - } +#if os(macOS) + public func steerWithWASD(entityId: EntityID, maxSpeed: Float, deltaTime: Float, turnSpeed: Float = 1.0, weight: Float = 1.0) { + if gameMode == false { + return + } - if isPhysicsComponentPaused(entityId: entityId) { - return - } + if isPhysicsComponentPaused(entityId: entityId) { + return + } - // Check for invalid deltaTime - guard deltaTime > 0 else { - handleError(.invalidDeltaTime, entityId) - return - } + // Check for invalid deltaTime + guard deltaTime > 0 else { + handleError(.invalidDeltaTime, entityId) + return + } - guard let physicsComponent = scene.get(component: PhysicsComponents.self, for: entityId) else { - handleError(.noPhysicsComponent, entityId) - return - } + guard let physicsComponent = scene.get(component: PhysicsComponents.self, for: entityId) else { + handleError(.noPhysicsComponent, entityId) + return + } - let currentPosition = getLocalPosition(entityId: entityId) - var targetPosition = currentPosition + let currentPosition = getLocalPosition(entityId: entityId) + var targetPosition = currentPosition - if InputSystem.shared.keyState.wPressed { - targetPosition.z += 1.0 - } + if InputSystem.shared.keyState.wPressed { + targetPosition.z += 1.0 + } - if InputSystem.shared.keyState.sPressed { - targetPosition.z -= 1.0 - } + if InputSystem.shared.keyState.sPressed { + targetPosition.z -= 1.0 + } - if InputSystem.shared.keyState.aPressed { - targetPosition.x -= 1.0 - } + if InputSystem.shared.keyState.aPressed { + targetPosition.x -= 1.0 + } - if InputSystem.shared.keyState.dPressed { - targetPosition.x += 1.0 - } + if InputSystem.shared.keyState.dPressed { + targetPosition.x += 1.0 + } - // Use the seek behavior to calculate the steering velocity adjustment - let finalVelocity = seek(entityId: entityId, targetPosition: targetPosition, maxSpeed: maxSpeed) * weight + // Use the seek behavior to calculate the steering velocity adjustment + let finalVelocity = seek(entityId: entityId, targetPosition: targetPosition, maxSpeed: maxSpeed) * weight - // Convert the velocity adjustment into a force for the physics system - let steeringForce = (finalVelocity * physicsComponent.mass) - applyForce(entityId: entityId, force: steeringForce) + // Convert the velocity adjustment into a force for the physics system + let steeringForce = (finalVelocity * physicsComponent.mass) + applyForce(entityId: entityId, force: steeringForce) - // Align orientation to face the target + // Align orientation to face the target - let targetDirection = normalize(targetPosition - currentPosition) + let targetDirection = safeNormalize(targetPosition - currentPosition) - alignOrientation(entityId: entityId, targetDirection: targetDirection, deltaTime: deltaTime, turnSpeed: turnSpeed) -} + alignOrientation(entityId: entityId, targetDirection: targetDirection, deltaTime: deltaTime, turnSpeed: turnSpeed) + } +#endif public func steerFlee(entityId: EntityID, threatPosition: simd_float3, maxSpeed: Float, deltaTime: Float, turnSpeed: Float = 1.0) { if gameMode == false { @@ -429,13 +455,13 @@ public func steerFlee(entityId: EntityID, threatPosition: simd_float3, maxSpeed: // Align orientation to face away from the threat let currentPosition = getPosition(entityId: entityId) - let threatDirection = normalize(threatPosition - currentPosition) + let threatDirection = safeNormalize(threatPosition - currentPosition) let fleeDirection = -threatDirection alignOrientation(entityId: entityId, targetDirection: fleeDirection, deltaTime: deltaTime, turnSpeed: turnSpeed) } -public func steerPursuit(entityId: EntityID, targetEntity: EntityID, maxSpeed: Float, deltaTime: Float, turnSpeed: Float = 1.0) { +public func steerPursuit(entityId: EntityID, targetEntity: EntityID, maxSpeed: Float, deltaTime: Float, turnSpeed: Float = 1.0, targetOffset: simd_float3 = simd_float3(0.0, 0.0, 0.0)) { if gameMode == false { return } @@ -461,7 +487,7 @@ public func steerPursuit(entityId: EntityID, targetEntity: EntityID, maxSpeed: F } // Use the pursuit behavior to calculate the steering velocity adjustment - let finalVelocity = pursuit(entityId: entityId, targetEntity: targetEntity, maxSpeed: maxSpeed) + let finalVelocity = pursuit(entityId: entityId, targetEntity: targetEntity, maxSpeed: maxSpeed, targetOffset: targetOffset) // Convert the velocity adjustment into a force for the physics system let steeringForce = (finalVelocity * physicsComponent.mass) @@ -469,16 +495,20 @@ public func steerPursuit(entityId: EntityID, targetEntity: EntityID, maxSpeed: F // Align orientation to face the predicted target position let position = getPosition(entityId: entityId) - let targetPosition = getPosition(entityId: targetEntity) + let targetPosition = getPosition(entityId: targetEntity) + targetOffset // Estimate where the target entity will be based on its current velocity let toTarget = targetPosition - position - let relativeHeading = dot(normalize(physicsTargetComponent.velocity), normalize(physicsComponent.velocity)) + let relativeHeading = dot( + safeNormalize(physicsTargetComponent.velocity), + safeNormalize(physicsComponent.velocity) + ) - let predictionTime = (relativeHeading > 0.95) ? (length(toTarget) / maxSpeed) : 0.5 + let predictionTime = (maxSpeed > 0.0 && relativeHeading > 0.95) ? (length(toTarget) / maxSpeed) : 0.5 let futurePosition = targetPosition + physicsTargetComponent.velocity * predictionTime - alignOrientation(entityId: entityId, targetDirection: futurePosition, deltaTime: deltaTime, turnSpeed: turnSpeed) + let futureDirection = futurePosition - position + alignOrientation(entityId: entityId, targetDirection: futureDirection, deltaTime: deltaTime, turnSpeed: turnSpeed) } public func steerFollowPath(entityId: EntityID, path: [simd_float3], maxSpeed: Float, deltaTime: Float, turnSpeed: Float = 1.0, waypointThreshold: Float = 0.5, weight: Float = 1.0) { @@ -502,7 +532,7 @@ public func steerFollowPath(entityId: EntityID, path: [simd_float3], maxSpeed: F var waypointIndex = getWaypointIndex(for: entityId) // Target the current waypoint - let targetWaypoint = path[waypointIndex] + var targetWaypoint = path[waypointIndex] let distanceToWaypoint = length(targetWaypoint - currentPosition) // Check if the entity has reached the current waypoint @@ -512,6 +542,7 @@ public func steerFollowPath(entityId: EntityID, path: [simd_float3], maxSpeed: F waypointIndex = 0 // Loop back to the first waypoint (or stop if needed) } setWaypointIndex(for: entityId, index: waypointIndex) + targetWaypoint = path[waypointIndex] } // Seek toward the current waypoint @@ -525,7 +556,8 @@ public func steerFollowPath(entityId: EntityID, path: [simd_float3], maxSpeed: F // Apply the force for movement applyForce(entityId: entityId, force: finalVelocity * physicsComponent.mass) - alignOrientation(entityId: entityId, targetDirection: targetWaypoint, deltaTime: deltaTime, turnSpeed: turnSpeed) + let targetDirection = targetWaypoint - currentPosition + alignOrientation(entityId: entityId, targetDirection: targetDirection, deltaTime: deltaTime, turnSpeed: turnSpeed) } public func steerAvoidObstacles(entityId: EntityID, obstacles: [EntityID], avoidanceRadius: Float, maxSpeed: Float, deltaTime: Float, turnSpeed: Float = 1.0) { @@ -558,7 +590,7 @@ public func steerAvoidObstacles(entityId: EntityID, obstacles: [EntityID], avoid // Only consider obstacles within the avoidance radius if distanceToObstacle < avoidanceRadius, distanceToObstacle > 0.01 { // Calculate avoidance force proportional to the distance (closer obstacles have stronger repulsion) - let normalizedDirection = normalize(directionToObstacle) + let normalizedDirection = safeNormalize(directionToObstacle) let forceMagnitude = (avoidanceRadius - distanceToObstacle) / avoidanceRadius let repulsionForce = -normalizedDirection * forceMagnitude * maxSpeed avoidanceForce += repulsionForce @@ -580,5 +612,5 @@ public func steerAvoidObstacles(entityId: EntityID, obstacles: [EntityID], avoid // Align the entity's orientation to its movement direction let velocity = getVelocity(entityId: entityId) - alignOrientation(entityId: entityId, targetDirection: normalize(velocity), deltaTime: deltaTime, turnSpeed: turnSpeed) + alignOrientation(entityId: entityId, targetDirection: safeNormalize(velocity), deltaTime: deltaTime, turnSpeed: turnSpeed) } diff --git a/Tests/UntoldEngineRenderTests/AnimationTest.swift b/Tests/UntoldEngineRenderTests/AnimationTest.swift index cf14779c..6c2dc214 100644 --- a/Tests/UntoldEngineRenderTests/AnimationTest.swift +++ b/Tests/UntoldEngineRenderTests/AnimationTest.swift @@ -52,6 +52,24 @@ final class AnimationTests: BaseRenderSetup { } } + func test_animationPlaybackSpeedScalesDeltaTime() { + guard let player = findEntity(name: "player") else { + XCTFail("Missing player entity") + return + } + + guard let animationComponent = scene.get(component: AnimationComponent.self, for: player) else { + XCTFail("Missing AnimationComponent for player entity") + return + } + + animationComponent.currentTime = 0.0 + setAnimationPlaybackSpeed(entityId: player, speed: 2.0) + AnimationSystem.shared.update(0.25) + + XCTAssertEqual(animationComponent.currentTime, 0.5, accuracy: 0.0001) + } + private func runSamples(save: (_ tex: MTLTexture, _ name: String) -> Void) throws { var last: Float = 0 for s in samples { diff --git a/Tests/UntoldEngineTests/CameraTest.swift b/Tests/UntoldEngineTests/CameraTest.swift index dbff3a03..79820505 100644 --- a/Tests/UntoldEngineTests/CameraTest.swift +++ b/Tests/UntoldEngineTests/CameraTest.swift @@ -153,6 +153,7 @@ final class CameraTests: XCTestCase { func testCameraFollowNoSmooth() { let target = createEntity() + registerComponent(entityId: target, componentType: LocalTransformComponent.self) translateTo(entityId: target, position: simd_float3(10, 0, 0)) setEntityName(entityId: target, name: "Target") @@ -168,4 +169,37 @@ final class CameraTests: XCTestCase { } XCTAssertEqual(cam.localPosition, simd_float3(10, 2, -5)) } + + func testCameraFollowDeadZone() { + let target = createEntity() + registerComponent(entityId: target, componentType: LocalTransformComponent.self) + translateTo(entityId: target, position: simd_float3(0.5, 0, 0)) + + moveCameraTo(entityId: camera, 0, 0, 0) + guard let cam = scene.get(component: CameraComponent.self, for: camera) else { + return + } + cam.rotation = .init(simd_float4x4.identity) + updateCameraViewMatrix(entityId: camera) + + cameraFollowDeadZone(entityId: camera, + targetEntity: target, + offset: simd_float3(0, 0, 0), + deadZoneExtents: simd_float3(1, 1, 1), + smoothFactor: 0, + deltaTime: 0) + + XCTAssertEqual(cam.localPosition, simd_float3(0, 0, 0)) + + translateTo(entityId: target, position: simd_float3(3, 0, 0)) + + cameraFollowDeadZone(entityId: camera, + targetEntity: target, + offset: simd_float3(0, 0, 0), + deadZoneExtents: simd_float3(1, 1, 1), + smoothFactor: 0, + deltaTime: 0) + + XCTAssertEqual(cam.localPosition, simd_float3(2, 0, 0)) + } } diff --git a/Tests/UntoldEngineTests/InputSystemTest.swift b/Tests/UntoldEngineTests/InputSystemTest.swift index 2946af18..123dd4c4 100644 --- a/Tests/UntoldEngineTests/InputSystemTest.swift +++ b/Tests/UntoldEngineTests/InputSystemTest.swift @@ -78,6 +78,8 @@ final class InputSystemTests: XCTestCase { XCTAssertEqual(input.kVK_ANSI_X, 7) XCTAssertEqual(input.kVK_ANSI_Y, 16) XCTAssertEqual(input.kVK_ANSI_Z, 6) + XCTAssertEqual(input.kVK_ANSI_J, 38) + XCTAssertEqual(input.kVK_ANSI_K, 40) XCTAssertEqual(input.kVK_ANSI_Space, 49) } diff --git a/Tests/UntoldEngineTests/SteeringSystemTest.swift b/Tests/UntoldEngineTests/SteeringSystemTest.swift index d6778cca..beb66ea0 100644 --- a/Tests/UntoldEngineTests/SteeringSystemTest.swift +++ b/Tests/UntoldEngineTests/SteeringSystemTest.swift @@ -114,6 +114,23 @@ final class SteeringSystemTests: XCTestCase { XCTAssertEqual(steering, expectedSteering) } + func testPursuitWithOffset() { + translateTo(entityId: targetEntityId, position: simd_float3(5, 10, 0)) + + let physicsComponent = scene.get(component: PhysicsComponents.self, for: targetEntityId) + + physicsComponent?.velocity = simd_float3(2, 0, 0) + + updatePhysicsSystem(deltaTime: 0.01) + + let targetOffset = simd_float3(0.0, -10.0, 0.0) + let steering = pursuit(entityId: entityId, targetEntity: targetEntityId, maxSpeed: maxSpeed, targetOffset: targetOffset) + + let expectedSteering = simd_float3(5, 0, 0) // Offset flattens to XZ plane + + XCTAssertEqual(steering, expectedSteering) + } + // MARK: - Evade Test func testEvade() { @@ -385,6 +402,41 @@ final class SteeringSystemTests: XCTestCase { XCTAssertEqual(distance(finalPosition, finalTargetEntityPosition), 0.0, accuracy: 0.1, "distances between entities is not close enough") } + func testSteerPursuitWithOffset() { + translateTo(entityId: entityId, position: simd_float3(0.0, 0.0, 1.0)) + translateTo(entityId: targetEntityId, position: simd_float3(0.0, 5.0, -5.0)) + + clearVelocity(entityId: entityId) + clearVelocity(entityId: targetEntityId) + + let targetPosition = simd_float3(20.0, 5.0, 0.0) + let targetOffset = simd_float3(0.0, -5.0, 0.0) + + let deltaTime: Float = 0.01 + var t: Float = 0.0 + let maxSimulationTime: Float = 10.0 + + while t < maxSimulationTime { + steerSeek(entityId: targetEntityId, targetPosition: targetPosition, maxSpeed: 1.0, deltaTime: 0.01) + steerPursuit(entityId: entityId, targetEntity: targetEntityId, maxSpeed: maxSpeed * 10.0, deltaTime: 0.01, targetOffset: targetOffset) + + updatePhysicsSystem(deltaTime: deltaTime) + t += deltaTime + + let position = getLocalPosition(entityId: entityId) + let targetEntityPosition = getLocalPosition(entityId: targetEntityId) + targetOffset + if distance(position, targetEntityPosition) < 0.1 { + break + } + } + + let finalPosition = getLocalPosition(entityId: entityId) + let finalTargetEntityPosition = getLocalPosition(entityId: targetEntityId) + targetOffset + + XCTAssertEqual(distance(finalPosition, finalTargetEntityPosition), 0.0, accuracy: 0.1, "offset pursuit did not converge") + XCTAssertEqual(finalPosition.y, 0.0, accuracy: 0.1, "pursuer should stay on XZ plane") + } + func testSteerFollowPath() { let path: [simd_float3] = [ simd_float3(1.0, 0.0, 0.0),