Created
November 11, 2025 13:34
-
-
Save Codelaby/3e99a4171abd5244fc9668510a2bf882 to your computer and use it in GitHub Desktop.
Learn the fundamentals of ECS (Entity-Component-System) of GameplayKit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")") | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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