Skip to content

Instantly share code, notes, and snippets.

@Codelaby
Created November 11, 2025 13:34
Show Gist options
  • Select an option

  • Save Codelaby/3e99a4171abd5244fc9668510a2bf882 to your computer and use it in GitHub Desktop.

Select an option

Save Codelaby/3e99a4171abd5244fc9668510a2bf882 to your computer and use it in GitHub Desktop.
Learn the fundamentals of ECS (Entity-Component-System) of GameplayKit
import SwiftUI
// MARK: Thumb Stick View
struct AnalogStickView: View {
/// Devuelve un valor normalizado en el rango [-1, 1] para x e y
@Binding var value: CGPoint
@State private var innerCircleLocation: CGPoint = .zero
private let thumbAreaColor: Color = .gray.opacity(0.3)
private let thumbColor: Color = .secondary
var body: some View {
GeometryReader { geometry in
let size = geometry.size
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let radius = min(size.width, size.height) / 2
let smallRadius: CGFloat = radius * 0.3
let bigRadius: CGFloat = radius - (smallRadius / 2)
ZStack {
// Círculo grande (zona activa)
Circle()
.fill(thumbAreaColor)
.frame(width: (bigRadius * 2), height: bigRadius * 2)
// Círculo pequeño (thumb)
Circle()
.fill(thumbColor)
.frame(width: smallRadius * 2, height: smallRadius * 2)
.position(innerCircleLocation == .zero ? center : innerCircleLocation)
}
.contentShape(Circle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
// Desplazamiento relativo al centro
let translation = CGPoint(
x: drag.location.x - center.x,
y: drag.location.y - center.y
)
let distance = sqrt(translation.x * translation.x + translation.y * translation.y)
let clampedDistance = min(distance, bigRadius - smallRadius / 2)
let angle = atan2(translation.y, translation.x)
// Posición visual del thumb
let newX = center.x + cos(angle) * clampedDistance
let newY = center.y + sin(angle) * clampedDistance
innerCircleLocation = CGPoint(x: newX, y: newY)
// Normalizar entre -1.0 y 1.0
let normalizedX = (translation.x / (bigRadius - smallRadius / 2))
let normalizedY = -(translation.y / (bigRadius - smallRadius / 2))
Task { @MainActor in
self.value = CGPoint(
x: min(max(normalizedX, -1), 1),
y: min(max(normalizedY, -1), 1)
)
}
}
.onEnded { _ in
// Vuelve al centro
//withAnimation(.smooth) {
innerCircleLocation = center
Task { @MainActor in
self.value = .zero
}
//}
}
)
.onAppear {
innerCircleLocation = center
}
}
.aspectRatio(1, contentMode: .fit)
}
}
// MARK: Demo
#Preview {
@Previewable @State var leftStick: CGPoint = .zero
AnalogStickView(value: $leftStick)
.frame(width: 180, height: 180)
Text("x: \(leftStick.x, specifier: "%.2f"), y: \(leftStick.y, specifier: "%.2f")")
}
import SwiftUI
import SpriteKit
import GameplayKit
// MARK: - COMPONENTS
/// Renderiza el sprite en la escena
final class RenderComponent: GKComponent {
let node: SKSpriteNode
init(textureName: String, zPosition: CGFloat = 0) {
let texture = SKTexture(imageNamed: textureName)
texture.filteringMode = .nearest
node = SKSpriteNode(texture: texture)
node.zPosition = zPosition
super.init()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
/// Limites de pantalla
final class ConstraintComponent: GKComponent {
private let margin: CGFloat
private var applied = false
init(margin: CGFloat = 0) {
self.margin = margin
super.init()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func applyIfNeeded(to node: SKNode, in size: CGSize) {
guard !applied else { return }
let halfW = node.frame.width / 2
let halfH = node.frame.height / 2
let xRange = SKRange(lowerLimit: halfW + margin,
upperLimit: size.width - halfW - margin)
let yRange = SKRange(lowerLimit: halfH + margin,
upperLimit: size.height - halfH - margin)
let constraint = SKConstraint.positionX(xRange, y: yRange)
node.constraints = [constraint]
applied = true
}
}
/// Movimiento inercial básico
final class MovementComponent: GKComponent {
// Estado de movimiento
private(set) var velocity = CGVector(dx: 0, dy: 0)
var inputDirection: CGPoint = .zero
// Parámetros configurables
private let acceleration: CGFloat = 100 // fuerza de empuje
private let maxSpeed: CGFloat = 200 // límite de velocidad
private let friction: CGFloat = 0.98 // rozamiento
private let stopThreshold: CGFloat = 1.0 // umbral para detener
/// Aplica actualización física al nodo cada frame
func update(node: SKNode, deltaTime: TimeInterval) {
let dt = CGFloat(deltaTime)
let dx = inputDirection.x
let dy = inputDirection.y
// Calcular magnitud del input del joystick
let magnitude = sqrt(dx * dx + dy * dy)
if magnitude > 0.01 {
// Normalizar dirección
let normalized = CGPoint(x: dx / magnitude, y: dy / magnitude)
// Intensidad (0..1)
let intensity = min(magnitude, 1.0)
// Aplicar aceleración proporcional a la intensidad
velocity.dx += normalized.x * acceleration * intensity * dt
velocity.dy += normalized.y * acceleration * intensity * dt
// Limitar la velocidad máxima
let speed = sqrt(velocity.dx * velocity.dx + velocity.dy * velocity.dy)
if speed > maxSpeed {
let ratio = maxSpeed / speed
velocity.dx *= ratio
velocity.dy *= ratio
}
} else {
// No hay input → aplicar fricción
velocity.dx *= friction
velocity.dy *= friction
// Detener completamente si es muy baja
if abs(velocity.dx) < stopThreshold { velocity.dx = 0 }
if abs(velocity.dy) < stopThreshold { velocity.dy = 0 }
}
// Aplicar movimiento al nodo
node.position.x += velocity.dx * dt
node.position.y += velocity.dy * dt
}
// func constrain(node: SKNode, in size: CGSize) {
// let halfW = node.frame.width / 2
// let halfH = node.frame.height / 2
//
// var pos = node.position
// if pos.x < halfW { pos.x = halfW; velocity.dx = 0 }
// if pos.x > size.width - halfW { pos.x = size.width - halfW; velocity.dx = 0 }
// if pos.y < halfH { pos.y = halfH; velocity.dy = 0 }
// if pos.y > size.height - halfH { pos.y = size.height - halfH; velocity.dy = 0 }
//
// node.position = pos
// }
func setInput(_ dir: CGPoint) { inputDirection = dir }
}
// MARK: - ENTITIES
final class BackgroundEntity: GKEntity {
init(size: CGSize) {
super.init()
let render = RenderComponent(textureName: "bg_space", zPosition: 0)
render.node.size = size
render.node.position = CGPoint(x: size.width / 2, y: size.height / 2)
addComponent(render)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
final class ShipEntity: GKEntity {
init(position: CGPoint) {
super.init()
let render = RenderComponent(textureName: "space_ship", zPosition: 2)
render.node.position = position
render.node.setScale(2.0)
addComponent(render)
addComponent(MovementComponent())
addComponent(ConstraintComponent(margin: 0))
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
var renderNode: SKSpriteNode? { component(ofType: RenderComponent.self)?.node }
var movement: MovementComponent? { component(ofType: MovementComponent.self) }
var constraint: ConstraintComponent? { component(ofType: ConstraintComponent.self) }
}
// MARK: - SCENE
final class ECSShipScene: SKScene {
private var entities = [GKEntity]()
private var lastUpdate: TimeInterval = 0
private var shipEntity: ShipEntity?
override func didMove(to view: SKView) {
setupScene()
}
private func setupScene() {
// Fondo
let bg = BackgroundEntity(size: size)
if let node = bg.component(ofType: RenderComponent.self)?.node {
addChild(node)
}
entities.append(bg)
// Nave
let ship = ShipEntity(position: CGPoint(x: size.width / 2, y: size.height / 2))
if let node = ship.renderNode {
addChild(node)
}
entities.append(ship)
shipEntity = ship
}
func moveAllDirection(_ value: CGPoint) {
shipEntity?.movement?.setInput(value)
}
override func update(_ currentTime: TimeInterval) {
var deltaTime = currentTime - lastUpdate
if lastUpdate == 0 { deltaTime = 0 }
lastUpdate = currentTime
for entity in entities {
guard let render = entity.component(ofType: RenderComponent.self) else { continue }
// Actualizar movimiento
if let move = entity.component(ofType: MovementComponent.self) {
move.update(node: render.node, deltaTime: deltaTime)
}
// Aplicar constraint si es necesario
if let constraint = entity.component(ofType: ConstraintComponent.self) {
constraint.applyIfNeeded(to: render.node, in: size)
}
}
}
}
// MARK: - SWIFTUI VIEW
struct ECSShipDemoView: View {
@State private var leftStick: CGPoint = .zero
let scene: ECSShipScene = {
let s = ECSShipScene(size: CGSize(width: 400, height: 400))
s.scaleMode = .aspectFit
s.backgroundColor = .clear
return s
}()
var body: some View {
VStack {
SpriteView(
scene: scene,
options: [.allowsTransparency],
debugOptions: [
.showsFPS,
.showsNodeCount,
.showsFields,
.showsPhysics
]
)
.frame(width: 400, height: 400)
.border(Color.blue)
AnalogStickView(value: $leftStick)
.frame(width: 180, height: 180)
}
.onChange(of: leftStick) { _, newValue in
scene.moveAllDirection(newValue)
}
}
}
#Preview {
ECSShipDemoView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment