Convert between UIKit and SpriteKit coordinate systems - swift

I'm new to iOS programming, and have almost no experience with SpriteKit, so please forgive me if this is a ridiculous question.
I've been trying to make a basic grid with a 2D array, and I would prefer to work with it from top-left being 0, 0.
After researching the differences in coordinate systems between UIKit and SpriteKit, I came across this answer about Converting Between View and Scene Coordinates but it doesn't seem to change the y value the way I thought it would. I am guessing that I'm not using it right, or maybe this is not what it's meant to do, I don't know.
When I try this:
let convertedCoordinates = convert(cellCoordinates, to: grid)
print(cellCoordinates.y, convertedCoordinates.y)
it doesn't seem to have any effect on the y value.
I have found that when I change to "y: -cy" in the line let cellCoordinates = CGPoint(x: cx, y: cy)
Then it does seem to work the way I am hoping for, but I don't know if that's the only solution or if doing this will work as expected under more complicated situations.
Here is the code I am working with:
import SpriteKit
import GameplayKit
class GameScene: SKScene {
override func didMove(to view: SKView) {
var background: SKShapeNode!
background = SKShapeNode(rectOf: CGSize(width: frame.size.width, height: frame.size.height))
background.fillColor = SKColor.lightGray
self.addChild(background)
let margin = CGFloat(50)
let width = frame.size.width - margin
let height = frame.size.height - margin
let centerX = frame.midX - width / 2
let centerY = frame.midY - height / 2
var grid: SKShapeNode!
grid = SKShapeNode(rectOf: CGSize(width: width, height: height))
grid.strokeColor = SKColor.clear
self.addChild(grid)
let numRows = 2
let numCols = 3
let cellWidth = width / CGFloat(numCols)
for r in 0..<numRows {
for c in 0..<numCols {
let cx = centerX + (cellWidth / 2) + (CGFloat(c) * cellWidth)
let cy = centerY + (cellWidth / 2) + (CGFloat(r) * cellWidth)
//***
let cellCoordinates = CGPoint(x: cx, y: cy)
//***
let cellNode = SKShapeNode(rectOf: CGSize(width: cellWidth, height: cellWidth))
let convertedCoordinates = convert(cellCoordinates, to: grid)
print(cellCoordinates.y, convertedCoordinates.y)
cellNode.strokeColor = SKColor.black
cellNode.lineWidth = 5
cellNode.fillColor = SKColor.darkGray
cellNode.position = convertedCoordinates
let textNode = SKLabelNode(text: String("\(r),\(c)"))
textNode.fontName = "Menlo"
textNode.fontSize = 60
textNode.verticalAlignmentMode = .center
textNode.position = convertedCoordinates
grid.addChild(cellNode)
grid.addChild(textNode)
}
}
}
}

This is more a philosophical answer than an implementation one. As far as somehow flipping SpriteKit's coordinate system, well, you're going to be fighting it constantly. Better to just embrace the system as it is.
The essence of your question though is more one of separation of model and view. When you say
I would prefer to work with it from top-left being 0, 0
what you mean is that mentally you're thinking of the game as a grid of cells with 0,0 at the top left. That's perfectly fine and natural. That's your model of the game. But what are you writing in the code?
let cx = centerX + (cellWidth / 2) + (CGFloat(c) * cellWidth)
let cy = centerY + (cellWidth / 2) + (CGFloat(r) * cellWidth)
let cellCoordinates = CGPoint(x: cx, y: cy)
let convertedCoordinates = convert(cellCoordinates, to: grid)
That's your view struggling to get out. You have the abstract model grid that you're indexing with r,c with 0,0 at the upper left and whose coordinates increase in unit steps down and to the right. Then there's the view of the model, which might depend on screen resolution, aspect ratio, device orientation, whatever. If you keep the two mentally separate, you'll usually find that you can isolate the translation between the two systems to a small interface. In those places you may have to do things like scale the axes or flip one of them, or stretch things in one direction to match aspect ratios.
In a case like this, if you start with your mental model with your preferred 0,0 in the upper left and think about how the game operates, it'll often be in terms of the cells. OK, that suggests that maybe a 2D array or an array of arrays is natural. Maybe the cells will eventually become a class in your game. They'll probably have a node property that stores the SpriteKit node. You might wind up with something like this:
struct boardPosition {
let row: Int
let col: Int
}
class Cell {
let pos: boardPosition
let node: SKNode
init(pos: boardPosition, in board: Board) {
self.pos = pos
node = SKShapeNode(...)
board.node.addChild(node)
}
}
class Board {
let cells: [[Cell]]
let node: SKNode
init(numRows: Int, numColumns: Int) {
...
}
func movePiece(from: boardPosition, to: boardPosition) {
let piece = cell[from.row][from.col].removePiece()
cell[to.row][to.col].addPiece(piece)
}
}
The operation of the game will be in terms of your mental model. The fact that the y-coordinates of the cells' SKNode nodes happen to decrease as the row index increases will be completely buried.

Set all nodes applicable and scene’s anchor point to 0,1 to get it to mount to the top left corner and set your world node’s (if you do not have one, I recommend adding it, it is a basic SKNode that you use to place all of your game nodes in, allowing you to use a separate node for things not applicable to the game world, like hud and overlays) yScale to -1 to have y increment downward instead of upward.
Edit:
When dealing with SKShapeNodes, you do not have to worry about the images being inversed unless you have an obscure shape. When designing the CGPath for the obscure shape, just flip it.
shape.path = shape.path!.copy(using:CGAffineTransform(scaleX:1,y:-1))
The bigger problem is SKShapeNode does not have anchor points. You instead need to move the entire CGPath
To do this, add the following line:
shape.path = shape.path!.copy(using:CGAffineTransform(translationX:shape.frame.width/2,y:shape.frame.height/2))
If dealing with SKSprite nodes in the future....
This will cause your assets to be upside down, so all you would need to do is have your assets flipped before import, use a secondary node to flip the y axis, or assign all nodes with a yScale of -1. Flipping all of your assets prior to import vertically would be the cheapest method, I believe you can flip it inside xcassets as well, but I need to verify that when I get back on a MacOS again.

Related

Swift. UIBezierPath shape detection

I working with UIBezierPath and shape detection. For painting im using "UIPanGestureRecognizer".
Example of code:
My shape definder
var gesture = UIPanGestureRecognizer()
.
.
.
view.addGestureRecognizer(gesture.onChange { \[weak self\] gesture in
let point = gesture.location(in: self.view)
let shapeL = CAShapeLayer()
shapeL.strokeColor = UIColor.black.cgColor
shapeL.lineWidth = 2
shapeL.fillColor = UIColor.clear.cgColor
switch gesture.state {
case .began:
//some code
currentBezierPath = UIBezierPath()
break
case .changed:
//some code
shapeLayer.path = self.currentBezierPath.cgPath
break
case .ended:
//define what user was painted(circle, rectangle, etc)
shapeDefinder(path: currentBezierPath)
break
default:
break
})
shapeDefinder
func shapeDefinder(path: UIBezierPath) {
if(path.hasFourRightAngles()){
// square
}
}
extension hasFourRightAngles
extension UIBezierPath {
func hasFourRightAngles() -> Bool {
guard self.currentPoint != .zero else {
// empty path cannot have angles
return false
}
let bounds = self.bounds
let points = [
bounds.origin,
CGPoint(x: bounds.minX, y: bounds.minY),
CGPoint(x: bounds.maxX, y: bounds.maxY),
CGPoint(x: bounds.minX, y: bounds.maxY)
]
let angleTolerance = 5.0 // in degrees
var rightAngleCount = 0
for i in 0...3 {
let p1 = points[i]
let p2 = points[(i+1)%4]
let p3 = points[(i+2)%4]
let angle = p2.angle(between: p1, and: p3)
if abs(angle - 90) <= angleTolerance {
rightAngleCount += 1
}
}
return rightAngleCount >= 4
}
}
and
extension CGPoint {
func angle(between p1: CGPoint, and p2: CGPoint) -\> CGFloat {
let dx1 = self.x - p1.x
let dy1 = self.y - p1.y
let dx2 = p2.x - self.x
let dy2 = p2.y - self.y
let dotProduct = dx1*dx2 + dy1*dy2
let crossProduct = dx1*dy2 - dx2*dy1
return atan2(crossProduct, dotProduct) \* 180 / .pi
}
}
but my method hasFourRightAngles() doesnt work, it always has true.
Cant understand how i can detect square(the user must draw exactly a square, if the user draws a circle, then the check should not pass.)
Maybe someone know about some library which works with UIBezierPath for detect shapes?
The bounds of a path are always a rectangle, no matter the shape, so you should expect this function to always return true. From the docs:
The value in this property represents the smallest rectangle that completely encloses all points in the path, including any control points for Bézier and quadratic curves.
If you want to consider the components of the path itself, you'd need to iterate over its components using it's CGPath. See applyWithBlock for how to get the elements. That said, this probably won't work very well, since you likely don't care precisely how the shape was drawn. If you go down this road, you'll probably want to do some work to simplify the curve first, and perhaps put the stokes in a useful order.
If the drawing pattern itself is the important thing (i.e. the user's gesture is what matters), then I would probably keep track of whether this could be a rectangle at each point of the drawing. Either it needs to be roughly colinear to the previous line, or roughly normal. And then the final point must be close to the original point.
The better approach is possibly to consider the final image of the shape, regardless of how it was drawn, and then classify it. For various algorithms to do that, see How to identify different objects in an image?
Your code to get the bounds of your path will not let you tell if the lines inside the path make right angles. As Rob says in his answer, the bounding box of a path will always be a rectangle, so your current test will always return true.
The bounding box of a circle will be a square, as will the box of any shape who's horizontal and vertical maxima and minima are equal.
It is possible to interrogate the internal elements of the underlying CGPath and look for a series of lines that make a square. I suggest searching for code that parses the elements of a CGPath.
Note that if you are checking freehand drawing, you will likely need some "slop" in your calculations to allow for shapes that are close to, but not exactly squares, or you will likely never find a perfect square.
Also, what if the path contains a square plus other shape elements? You will need to decide how to handle situations like that.

Aligning ARFaceAnchor with SpriteKit overlay

I'm trying to calculate SpriteKit overlay content position (not just overlaying visual content) over specific geometry points ARFaceGeometry/ARFaceAnchor.
I'm using SCNSceneRenderer.projectPoint from the calculated world coordinate, but the result is y inverted and not aligned to the camera image:
let vertex4 = vector_float4(0, 0, 0, 1)
let modelMatrix = faceAnchor.transform
let world_vertex4 = simd_mul(modelMatrix, vertex4)
let pt3 = SCNVector3(x: Float(world_vertex4.x),
y: Float(world_vertex4.y),
z: Float(world_vertex4.z))
let sprite_pt = renderer.projectPoint(pt3)
// To visualize sprite_pt
let dot = SKSpriteNode(imageNamed: "dot")
dot.size = CGSize(width: 7, height: 7)
dot.position = CGPoint(x: CGFloat(sprite_pt.x),
y: CGFloat(sprite_pt.y))
overlayScene.addChild(dot)
In my experience, the screen coordinates given by ARKit's projectPoint function are directly usable when drawing to, for example, a CALayer. This means they follow iOS coordinates as described here, where the origin is in the upper left and y is inverted.
SpriteKit has its own coordinate system:
The unit coordinate system places the origin at the bottom left corner of the frame and (1,1) at the top right corner of the frame. A sprite’s anchor point defaults to (0.5,0.5), which corresponds to the center of the frame.
Finally, SKNodes are placed in an SKScene which has its origin on the bottom left. You should ensure that your SKScene is the same size as your actual view, or else the origin may not be at the bottom left of the view and thus your positioning of the node from view coordinates my be incorrect. The answer to this question may help, in particular checking the AspectFit or AspectFill of your view to ensure your scene is being scaled down.
The Scene's origin is in the bottom left and depending on your scene size and scaling it may be off screen. This is where 0,0 is. So every child you add will start there and work its way right and up based on position. A SKSpriteNode has its origin in the center.
So the two basic steps to convert from view coordinates and SpriteKit coordinates would be 1) inverting the y-axis so your origin is in the bottom left, and 2) ensuring that your SKScene frame matches your view frame.
I can test this out more fully in a bit and edit if there are any issues
Found the transformation that works using camera.projectPoint instead of the renderer.projectPoint.
To scale the points correctly on the spritekit: set scaleMode=.aspectFill
I updated https://github.com/AnsonT/ARFaceSpriteKitMapping to demo this.
guard let faceAnchor = anchor as? ARFaceAnchor,
let camera = sceneView.session.currentFrame?.camera,
let sie = overlayScene?.size
else { return }
let modelMatrix = faceAnchor.transform
let vertices = faceAnchor.geometry.vertices
for vertex in vertices {
let vertex4 = vector_float4(vertex.x, vertex.y, vertex.z, 1)
let world_vertex4 = simd_mul(modelMatrix, vertex4)
let world_vector3 = simd_float3(x: world_vertex4.x, y: world_vertex4.y, z: world_vertex4.z)
let pt = camera.projectPoint(world_vector3, orientation: .portrait, viewportSize: size)
let dot = SKSpriteNode(imageNamed: "dot")
dot.size = CGSize(width: 7, height: 7)
dot.position = CGPoint(x: CGFloat(pt.x), y: size.height - CGFloat(pt.y))
overlayScene?.addChild(dot)
}

SpriteKit: using SKView in UIView instead of initializing a Game project

Completely new to SpriteKit. Currently I have a UIView, and I want to add a sprite node to it (like a small UIImageView, but I want animation for it so using SpriteKit). Therefore I didn't initialize my project to be a game project, as found in almost all of tutorials for SpriteKit. I've found a note here: link and what I have now is sth like:
func initializeImage() {
let imageView = SKView()
imageView.frame = CGRect(x: self.frame.width / 2 - Constants.imageWidth / 2, y: self.frame.height - Constants.imageHeight, width: Constants.imageWidth, height: Constants.imageHeight)
// so place it somewhere in the bottom middle of the whole frame
let sheet = SpriteSheet(texture: ...)
let sprite = SKSpriteNode(texture: sheet.itemFor(column: 0, row: 0))
sprite.position = imageView.center //basically the same position as the imageView.frame's x and y value
let scene = SKScene(size: imageView.frame.size)
scene.backgroundColor = SKColor.clear
scene.addChild(sprite)
imageView.presentScene(scene)
self.frame.addSubview(imageView)
}
The SpriteSheet is similar to this: sprite sheet; it's essentially cutting an image atlas and divide it into smaller images. I tracked the process and this step is indeed giving the smaller image (the var 'sprite'). But if running I only have a black square now (should be the size as defined by Constants). If I set scene.backgroundColor to be white then it's white. May I know how I should proceed from here, as how should I make the sprite showing up?
All of your code looks good except for this:
sprite.position = imageView.center // basically the same position as the imageView.frame's x and y value
That is basically not the position you think it is. The coordinate system in SpriteKit is a) relative to the (SK)scene, not to whatever view the SKView is contained in, and b) flipped vertically relative to the UIKit coordinate system. If you want a sprite centered in the scene, you probably want to set its position based on the scene's size:
sprite.position = CGPoint(x: scene.size.width / 2, y: scene.size.height / 2)
By the way, the external SpriteSheet code might not be needed (and you're more likely to benefit from Apple's optimizations) if you slice up your sprite sheet and put it in an Xcode asset catalog.

Making sprite appear at random position

I'm trying to make a sprite "comet" appear at a random position at random times. So far, I wanted to test if my random position code works, however, I can't seem to even see the sprite. This is my code:
func spawnAtRandomPosition() {
let height = self.view!.frame.height
let width = self.view!.frame.width
let randomPosition = CGPointMake(CGFloat(arc4random()) % height, CGFloat(arc4random()) % width)
let sprite = SKSpriteNode(imageNamed: "comet")
sprite.position = randomPosition
self.addChild(sprite)
}
As I said, I'm not seeing anything. Any help? If you already know how to make it appear at a random time that would be appreciated as well, because that's having problems of its own, however this is my focus right now. Thanks!
Your code for setting the random position is incorrect. Additionally, your code has issues that should make it impossible to compile in Swift 3. Your full function should look like this:
func spawnAtRandomPosition() {
let height = UInt32(self.size.height)
let width = UInt32(self.size.width)
let randomPosition = CGPoint(x: Int(arc4random_uniform(width)), y: Int(arc4random_uniform(height)))
let sprite = SKSpriteNode(imageNamed: "comet")
sprite.position = randomPosition
self.addChild(sprite)
}
Note the changes to randomPosition and the height and width:
let randomPosition = CGPoint(x: Int(arc4random_uniform(width)), y: Int(arc4random_uniform(height)))
This determines a random value between 0 and your width and does the same thing for the height.
As for the height and width, see #Whirlwind's comment on the question explaining the difference between the view and the scene.
Additionally, you may want to check if you're setting up your node properly (set size, try with fixed location, etc) before you test the random positioning, to determine where the problem truly lies.
I came up with this bit of code and it works for me:
let randomNum = CGPoint(x:Int (arc4random() % 1000), y: 1)
let actionMove = SKAction.moveTo(randomNum, duration: 1)
I only wanted the x to be randomized, however if you need "y" to be randomized as well, you can just copy the specification to x to y.

How to generate a new node when another one hits a certain y value

I have a circle moving up a line, and when that circle reaches a certain y point, how can I make it so that another node would generate from below?
Here is the code I currently have for populating the circles, but I am not able to use it with a physics body, as it generates too many nodes and slows down my app:
func createCirclesOnLine(line: CGFloat) {
var currentY : CGFloat = -110
let maxY = self.size.width * 15
let spacing : CGFloat = 120
while currentY < maxY {
let circle = SKSpriteNode(imageNamed: "first#2x")
circle.physicsBody?.dynamic = false
circle.position = CGPointMake(line, currentY)
//circle.physicsBody?.restitution = -900
circle.size = CGSizeMake(75, 75)
// circle.physicsBody = SKPhysicsBody(rectangleOfSize: circle.size)
let up = SKAction.moveByX(0, y: 9000, duration: 90)
circle.runAction(up)
foregroundNode.addChild(circle)
currentY += CGFloat((random() % 400) + 70)
}
Will post more code if necessary.
There are two ways you can go about this. One is to simply check every circle's y position to see if it's above the screen. You'll need a reference to the circles so...
class GameScene: SKScene {
var circles = Array<SKSpriteNode>()
...
In your createCirlcesOnLine function, add each circle to the array as you create it.
...
self.addChild(circle)
circles.append(circle)
Then, in your update method, enumerate through the circles to see if any of them are above the top of the screen.
override func update(currentTime: NSTimeInterval) {
for circle in circles {
if circle.position.y > self.size.height + circle.size.height/2 {
//Send circle back to the bottom using the circle's position property
}
}
}
This solution will work but causes a lot of unnecessary checks on every update cycle.
A second more efficient (and slightly more complicated) recommendation is to add an invisible node above the top of the screen that stretches the screen width. When the circle collides with it, just move it to the bottom of the screen. Look into implementing the SKPhysicsContactDelegate protocol and what needs to happen for that to work. If you run into problems with this solution, post a separate question with those issues.