Created
November 12, 2025 14:00
-
-
Save Codelaby/b53177a25587c070652f65466057a933 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 | |
| import SpriteKit | |
| import GameplayKit | |
| // MARK: - STATE MACHINES | |
| final class IdleState: GKState { | |
| weak var ship: ShipEntity? | |
| override func didEnter(from previousState: GKState?) { | |
| ship?.hideExhaustMarker() | |
| } | |
| override func isValidNextState(_ stateClass: AnyClass) -> Bool { | |
| stateClass is MovingState.Type | |
| } | |
| } | |
| final class MovingState: GKState { | |
| weak var ship: ShipEntity? | |
| override func didEnter(from previousState: GKState?) { | |
| ship?.showExhaustMarker() | |
| } | |
| override func willExit(to nextState: GKState) { | |
| ship?.hideExhaustMarker() | |
| } | |
| override func isValidNextState(_ stateClass: AnyClass) -> Bool { | |
| stateClass is IdleState.Type | |
| } | |
| } | |
| // 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 ship = entity as? ShipEntity | |
| 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 | |
| } | |
| // Detect if movement is primarily upward | |
| let angle = atan2(dy, dx) | |
| let wideUp = angle > .pi/6 && angle < 5 * .pi/6 // 30° to 150° | |
| if wideUp { | |
| ship?.stateMachine.enter(MovingState.self) // Cambiar estado | |
| } else { | |
| ship?.stateMachine.enter(IdleState.self) | |
| } | |
| } 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 } | |
| ship?.stateMachine.enter(IdleState.self) | |
| } | |
| // 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 { | |
| let stateMachine: GKStateMachine | |
| init(position: CGPoint) { | |
| let render = RenderComponent(textureName: "space_ship", zPosition: 2) | |
| render.node.position = position | |
| render.node.setScale(2.0) | |
| let movement = MovementComponent() | |
| let constraint = ConstraintComponent(margin: 0) | |
| // Crear instancias de estados | |
| let idle = IdleState() | |
| let moving = MovingState() | |
| stateMachine = GKStateMachine(states: [idle, moving]) | |
| super.init() | |
| // Vincular referencias | |
| idle.ship = self | |
| moving.ship = self | |
| stateMachine.enter(IdleState.self) | |
| addComponent(render) | |
| addComponent(movement) | |
| addComponent(constraint) | |
| } | |
| 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) } | |
| } | |
| // Thrust flame | |
| extension ShipEntity { | |
| func showExhaustMarker() { | |
| guard let node = renderNode else { return } | |
| // Evitar duplicados | |
| if node.childNode(withName: "exhaust") != nil { return } | |
| let size = CGSize(width: 10, height: 10) | |
| let marker = SKShapeNode(rectOf: size, cornerRadius: 2) | |
| marker.name = "exhaust" | |
| marker.fillColor = .yellow | |
| marker.strokeColor = .clear | |
| marker.position = CGPoint(x: 0, y: -node.size.height / 2 - 8) | |
| node.addChild(marker) | |
| } | |
| func hideExhaustMarker() { | |
| renderNode?.childNode(withName: "exhaust")?.removeFromParent() | |
| } | |
| } | |
| // 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