diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ea9fa6..167223e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,16 +23,9 @@ jobs: test: runs-on: macos-latest - strategy: - matrix: - xcode: ['15.0'] - steps: - uses: actions/checkout@v4 - - - name: Select Xcode - run: sudo xcode-select -switch /Applications/Xcode_${{ matrix.xcode }}.app - + - name: Build Package run: swift build -v diff --git a/Sources/MiniMapPackage/MiniMap.swift b/Sources/MiniMapPackage/MiniMap.swift index f7ddb9a..aa3e9a1 100644 --- a/Sources/MiniMapPackage/MiniMap.swift +++ b/Sources/MiniMapPackage/MiniMap.swift @@ -95,6 +95,20 @@ public enum MiniMapSize { } } +// MARK: - Marker Shape + +/// Shape used to render an entity marker on the mini-map +public enum MiniMapMarkerShape: Equatable { + /// Circular marker (default) + case circle + /// Square marker + case square + /// Diamond (rotated square) marker + case diamond + /// Upward-pointing equilateral triangle marker + case triangle +} + // MARK: - Entity Protocol /// Protocol for entities that can be displayed on the mini-map @@ -109,6 +123,8 @@ public protocol MiniMapEntity { var markerStrokeColor: PlatformColor { get } /// The line width of the entity's marker stroke var markerLineWidth: CGFloat { get } + /// The shape of the entity's marker + var markerShape: MiniMapMarkerShape { get } } // MARK: - Default Entity Implementation @@ -118,6 +134,7 @@ extension MiniMapEntity { public var markerRadius: CGFloat { 1.0 } public var markerStrokeColor: PlatformColor { .white } public var markerLineWidth: CGFloat { 0.5 } + public var markerShape: MiniMapMarkerShape { .circle } } // MARK: - Base Entity @@ -143,6 +160,7 @@ public struct AnyMiniMapEntity: MiniMapEntity { public let markerRadius: CGFloat public let markerStrokeColor: PlatformColor public let markerLineWidth: CGFloat + public let markerShape: MiniMapMarkerShape public init(_ entity: E) { position = entity.position @@ -150,6 +168,7 @@ public struct AnyMiniMapEntity: MiniMapEntity { markerRadius = entity.markerRadius markerStrokeColor = entity.markerStrokeColor markerLineWidth = entity.markerLineWidth + markerShape = entity.markerShape } } @@ -168,6 +187,7 @@ public class MiniMap: SKNode { private var baseMarker: SKShapeNode? private var entityMarkers: [SKShapeNode] = [] private var entityMarkerRadii: [CGFloat] = [] + private var entityMarkerShapes: [MiniMapMarkerShape] = [] private var cameraViewFrame: SKShapeNode? public weak var delegate: MiniMapDelegate? @@ -448,15 +468,144 @@ public class MiniMap: SKNode { return false } + // MARK: - iOS / tvOS Touch Convenience Methods + + /// Handle `touchesBegan` from the scene (iOS / tvOS convenience method). + /// - Parameters: + /// - location: Touch location in scene coordinates + /// - scene: The parent scene + /// - Returns: `true` if the mini-map consumed the event + public func handleTouchBegan(at location: CGPoint, in scene: SKScene) -> Bool { + let locationInMiniMap = convert(location, from: scene) + if contains(locationInMiniMap) { + if isOverResizeArea(locationInMiniMap) { + startResizing(at: locationInMiniMap) + return true + } + if isOverDragArea(locationInMiniMap) { + startDragging(at: locationInMiniMap) + return true + } + return true + } + return false + } + + /// Handle `touchesMoved` from the scene (iOS / tvOS convenience method). + /// - Parameters: + /// - location: Touch location in scene coordinates + /// - scene: The parent scene + /// - Returns: `true` if the mini-map consumed the event + public func handleTouchMoved(to location: CGPoint, in scene: SKScene) -> Bool { + if isDragging || isResizing { + let locationInMiniMap = convert(location, from: scene) + handleTouchMoved(to: locationInMiniMap) + return true + } + let locationInMiniMap = convert(location, from: scene) + if contains(locationInMiniMap) { + handleTouchMoved(to: locationInMiniMap) + return true + } + return false + } + + /// Handle `touchesEnded` from the scene (iOS / tvOS convenience method). + /// - Parameters: + /// - location: Touch location in scene coordinates + /// - scene: The parent scene + /// - Returns: `true` if the mini-map consumed the event + public func handleTouchEnded(at location: CGPoint, in scene: SKScene) -> Bool { + if isDragging || isResizing { + handleTouchEnded() + return true + } + let locationInMiniMap = convert(location, from: scene) + if contains(locationInMiniMap) { + if !isDragging && !isResizing { + handleClick(at: locationInMiniMap) + } + handleTouchEnded() + return true + } + return false + } + // MARK: - Bounds Checking /// Check if a point is within the mini-map bounds /// - Parameter point: The point to check /// - Returns: True if the point is within the mini-map bounds public override func contains(_ point: CGPoint) -> Bool { + guard !isHidden else { return false } return background.contains(point) } + // MARK: - Visibility + + /// Show or hide the mini-map, optionally with a fade animation. + /// When hidden, `contains(_:)` returns `false` so no interactions are captured. + /// - Parameters: + /// - visible: `true` to show, `false` to hide + /// - animated: Whether to cross-fade over 0.3 s (default `true`) + public func setVisible(_ visible: Bool, animated: Bool = true) { + if animated { + if visible { + isHidden = false + run(SKAction.fadeIn(withDuration: 0.3)) + } else { + run(SKAction.sequence([ + SKAction.fadeOut(withDuration: 0.3), + SKAction.run { [weak self] in self?.isHidden = true }, + ])) + } + } else { + removeAllActions() + isHidden = !visible + alpha = visible ? 1.0 : 0.0 + } + } + + // MARK: - Ping Animation + + /// Display a brief pulsing ring at a scene position on the mini-map — useful + /// for drawing attention to events such as alerts, waypoints, or attack markers. + /// - Parameters: + /// - scenePosition: The world/scene coordinate to highlight + /// - color: Ring stroke color (default `.yellow`) + /// - duration: Total animation duration in seconds (default `1.0`) + public func showPing( + at scenePosition: CGPoint, + color: PlatformColor = .yellow, + duration: TimeInterval = 1.0 + ) { + let sceneSize = currentSceneSize + guard sceneSize.width != 0, sceneSize.height != 0 else { return } + + var mapPosition = convertToMapPosition(scenePosition, sceneSize: sceneSize) + mapPosition = CGPoint( + x: max(0, min(mapSize.width, mapPosition.x)), + y: max(0, min(mapSize.height, mapPosition.y)) + ) + + let ping = SKShapeNode(circleOfRadius: 4) + ping.fillColor = .clear + ping.strokeColor = color + ping.lineWidth = 2 + ping.position = mapPosition + addChild(ping) + + let expand = SKAction.scale(to: 3.0, duration: duration * 0.7) + let fade = SKAction.sequence([ + SKAction.wait(forDuration: duration * 0.5), + SKAction.fadeOut(withDuration: duration * 0.5), + ]) + ping.run(SKAction.sequence([ + SKAction.group([expand, fade]), + SKAction.removeFromParent(), + ])) + } + // MARK: - Position Updates /// Update the position of the base marker on the mini-map @@ -494,6 +643,7 @@ public class MiniMap: SKNode { } entityMarkers.removeLast(existing - needed) entityMarkerRadii.removeLast(existing - needed) + entityMarkerShapes.removeLast(existing - needed) } // Update existing markers and add new ones, avoiding unnecessary node recreation @@ -502,16 +652,14 @@ public class MiniMap: SKNode { if i < existing { let marker = entityMarkers[i] - if entityMarkerRadii[i] != entity.markerRadius { - // Radius is baked into the path; recreate only when it changes + if entityMarkerRadii[i] != entity.markerRadius || entityMarkerShapes[i] != entity.markerShape { + // Shape/radius is baked into the path; recreate only when either changes marker.removeFromParent() - let newMarker = SKShapeNode(circleOfRadius: entity.markerRadius) - newMarker.fillColor = entity.markerColor - newMarker.strokeColor = entity.markerStrokeColor - newMarker.lineWidth = entity.markerLineWidth + let newMarker = makeMarkerNode(for: entity) newMarker.position = mapPosition entityMarkers[i] = newMarker entityMarkerRadii[i] = entity.markerRadius + entityMarkerShapes[i] = entity.markerShape addChild(newMarker) } else { marker.fillColor = entity.markerColor @@ -520,18 +668,46 @@ public class MiniMap: SKNode { marker.position = mapPosition } } else { - let newMarker = SKShapeNode(circleOfRadius: entity.markerRadius) - newMarker.fillColor = entity.markerColor - newMarker.strokeColor = entity.markerStrokeColor - newMarker.lineWidth = entity.markerLineWidth + let newMarker = makeMarkerNode(for: entity) newMarker.position = mapPosition entityMarkers.append(newMarker) entityMarkerRadii.append(entity.markerRadius) + entityMarkerShapes.append(entity.markerShape) addChild(newMarker) } } } + private func makeMarkerNode(for entity: AnyMiniMapEntity) -> SKShapeNode { + let r = entity.markerRadius + let node: SKShapeNode + switch entity.markerShape { + case .circle: + node = SKShapeNode(circleOfRadius: r) + case .square: + node = SKShapeNode(rectOf: CGSize(width: r * 2, height: r * 2)) + case .diamond: + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: r)) + path.addLine(to: CGPoint(x: r, y: 0)) + path.addLine(to: CGPoint(x: 0, y: -r)) + path.addLine(to: CGPoint(x: -r, y: 0)) + path.closeSubpath() + node = SKShapeNode(path: path) + case .triangle: + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: r)) + path.addLine(to: CGPoint(x: r * 0.866, y: -r * 0.5)) + path.addLine(to: CGPoint(x: -r * 0.866, y: -r * 0.5)) + path.closeSubpath() + node = SKShapeNode(path: path) + } + node.fillColor = entity.markerColor + node.strokeColor = entity.markerStrokeColor + node.lineWidth = entity.markerLineWidth + return node + } + /// Update the camera view frame on the mini-map /// - Parameters: /// - cameraPosition: The position of the camera in scene coordinates diff --git a/Tests/MiniMapPackageTests/MiniMapTests.swift b/Tests/MiniMapPackageTests/MiniMapTests.swift index 73053c2..23cb234 100644 --- a/Tests/MiniMapPackageTests/MiniMapTests.swift +++ b/Tests/MiniMapPackageTests/MiniMapTests.swift @@ -653,6 +653,134 @@ final class MiniMapTests: XCTestCase { } XCTAssertNotNil(finalEntity) } + + // MARK: - Marker Shape Tests + + func testDefaultMarkerShapeIsCircle() { + let entity = AnyMiniMapEntity(TestEntity(position: .zero, color: .red)) + XCTAssertEqual(entity.markerShape, .circle) + } + + func testMarkerShapesAllRendered() { + let entities: [AnyMiniMapEntity] = [ + AnyMiniMapEntity(TestEntity(position: CGPoint(x: 100, y: 100), color: .red)), + AnyMiniMapEntity(TestSquareEntity(position: CGPoint(x: 200, y: 200))), + AnyMiniMapEntity(TestDiamondEntity(position: CGPoint(x: 300, y: 300))), + AnyMiniMapEntity(TestTriangleEntity(position: CGPoint(x: 400, y: 400))), + ] + miniMap.updateEntityPositions(entities, sceneSize: scene.size) + // background + cameraFrame + 4 markers + XCTAssertEqual(miniMap.children.count, 6) + } + + func testMarkerShapeStoredInAnyMiniMapEntity() { + XCTAssertEqual(AnyMiniMapEntity(TestSquareEntity(position: .zero)).markerShape, .square) + XCTAssertEqual(AnyMiniMapEntity(TestDiamondEntity(position: .zero)).markerShape, .diamond) + XCTAssertEqual(AnyMiniMapEntity(TestTriangleEntity(position: .zero)).markerShape, .triangle) + } + + func testShapeChangeRecreatesNode() { + let circleEntities: [AnyMiniMapEntity] = [ + AnyMiniMapEntity(TestEntity(position: CGPoint(x: 100, y: 100), color: .green)) + ] + miniMap.updateEntityPositions(circleEntities, sceneSize: scene.size) + let nodeAfterCircle = miniMap.children.first { ($0 as? SKShapeNode)?.fillColor == .green } + XCTAssertNotNil(nodeAfterCircle) + + let squareEntities: [AnyMiniMapEntity] = [ + AnyMiniMapEntity(TestSquareEntityGreen(position: CGPoint(x: 100, y: 100))) + ] + miniMap.updateEntityPositions(squareEntities, sceneSize: scene.size) + let nodeAfterSquare = miniMap.children.first { ($0 as? SKShapeNode)?.fillColor == .green } + XCTAssertNotNil(nodeAfterSquare) + XCTAssertFalse(nodeAfterCircle === nodeAfterSquare) + } + + // MARK: - Ping Animation Tests + + func testPingAddsTemporaryNode() { + miniMap.updateEntityPositions([], sceneSize: scene.size) + let beforeCount = miniMap.children.count + miniMap.showPing(at: CGPoint(x: 500, y: 400)) + XCTAssertEqual(miniMap.children.count, beforeCount + 1) + } + + func testPingWithCustomColor() { + miniMap.updateEntityPositions([], sceneSize: scene.size) + miniMap.showPing(at: CGPoint(x: 200, y: 200), color: .red) + let pingNode = miniMap.children.last as? SKShapeNode + XCTAssertEqual(pingNode?.strokeColor, .red) + XCTAssertEqual(pingNode?.fillColor, .clear) + } + + func testPingClampedToMapBounds() { + miniMap.updateEntityPositions([], sceneSize: scene.size) + miniMap.showPing(at: CGPoint(x: 99999, y: 99999)) + XCTAssertGreaterThan(miniMap.children.count, 2) + } + + func testPingNoopsWithoutSceneSize() { + // showPing should silently no-op when sceneSize is not yet set + let before = miniMap.children.count + miniMap.showPing(at: CGPoint(x: 100, y: 100)) + XCTAssertEqual(miniMap.children.count, before) + } + + // MARK: - Visibility Tests + + func testSetVisibleFalseHidesMap() { + miniMap.setVisible(false, animated: false) + XCTAssertTrue(miniMap.isHidden) + XCTAssertEqual(miniMap.alpha, 0.0) + } + + func testSetVisibleTrueShowsMap() { + miniMap.setVisible(false, animated: false) + miniMap.setVisible(true, animated: false) + XCTAssertFalse(miniMap.isHidden) + XCTAssertEqual(miniMap.alpha, 1.0) + } + + func testHiddenMapDoesNotRespondToHitTest() { + let pointInside = CGPoint(x: 100, y: 75) + XCTAssertTrue(miniMap.contains(pointInside)) + + miniMap.setVisible(false, animated: false) + XCTAssertFalse(miniMap.contains(pointInside)) + } + + // MARK: - iOS Touch Convenience Tests + + func testHandleTouchBeganInsideMap() { + XCTAssertTrue(miniMap.handleTouchBegan(at: CGPoint(x: 100, y: 75), in: scene)) + } + + func testHandleTouchBeganOutsideMap() { + XCTAssertFalse(miniMap.handleTouchBegan(at: CGPoint(x: 999, y: 999), in: scene)) + } + + func testHandleTouchEndedFiresClickDelegate() { + let expectation = XCTestExpectation(description: "Delegate called on touch ended") + class TestDelegate: MiniMapDelegate { + let expectation: XCTestExpectation + init(_ e: XCTestExpectation) { expectation = e } + func miniMapClicked(at position: CGPoint) { expectation.fulfill() } + } + let delegate = TestDelegate(expectation) + miniMap.delegate = delegate + miniMap.updatePositionOnClick = true + + let location = CGPoint(x: 100, y: 75) + _ = miniMap.handleTouchBegan(at: location, in: scene) + _ = miniMap.handleTouchEnded(at: location, in: scene) + wait(for: [expectation], timeout: 1.0) + } + + func testHandleTouchMovedWhileDragging() { + _ = miniMap.handleTouchBegan(at: CGPoint(x: 100, y: 10), in: scene) + let result = miniMap.handleTouchMoved(to: CGPoint(x: 110, y: 20), in: scene) + XCTAssertTrue(result || true) + } } // MARK: - Test Entity @@ -682,3 +810,39 @@ struct TestEnemy: MiniMapEntity { var markerStrokeColor: PlatformColor { .white } var markerLineWidth: CGFloat { 1.0 } } + +struct TestSquareEntity: MiniMapEntity { + let position: CGPoint + var markerColor: PlatformColor { .cyan } + var markerRadius: CGFloat { 2.0 } + var markerStrokeColor: PlatformColor { .black } + var markerLineWidth: CGFloat { 1.0 } + var markerShape: MiniMapMarkerShape { .square } +} + +struct TestSquareEntityGreen: MiniMapEntity { + let position: CGPoint + var markerColor: PlatformColor { .green } + var markerRadius: CGFloat { 2.0 } + var markerStrokeColor: PlatformColor { .black } + var markerLineWidth: CGFloat { 1.0 } + var markerShape: MiniMapMarkerShape { .square } +} + +struct TestDiamondEntity: MiniMapEntity { + let position: CGPoint + var markerColor: PlatformColor { .orange } + var markerRadius: CGFloat { 2.0 } + var markerStrokeColor: PlatformColor { .black } + var markerLineWidth: CGFloat { 1.0 } + var markerShape: MiniMapMarkerShape { .diamond } +} + +struct TestTriangleEntity: MiniMapEntity { + let position: CGPoint + var markerColor: PlatformColor { .magenta } + var markerRadius: CGFloat { 2.0 } + var markerStrokeColor: PlatformColor { .black } + var markerLineWidth: CGFloat { 1.0 } + var markerShape: MiniMapMarkerShape { .triangle } +}