Created
November 12, 2025 17:57
-
-
Save Codelaby/707f08264ccd96533c19a0f7d8ce87ec 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: Utils | |
| private extension CGVector { | |
| var length: CGFloat { sqrt(dx * dx + dy * dy) } | |
| } | |
| // MARK: - MOVEMENT STATES (solo transiciones, SIN update) | |
| final class MovementIdleState: GKState { | |
| unowned let movement: MovementComponent | |
| init(_ movement: MovementComponent) { self.movement = movement } | |
| override func didEnter(from previousState: GKState?) { | |
| movement.velocity = .zero | |
| //movement.flame?.stateMachine.enter(FlameOffState.self) | |
| } | |
| override func isValidNextState(_ stateClass: AnyClass) -> Bool { | |
| stateClass is MovementMovingState.Type | |
| } | |
| } | |
| final class MovementMovingState: GKState { | |
| unowned let movement: MovementComponent | |
| init(_ movement: MovementComponent) { self.movement = movement } | |
| override func didEnter(from previousState: GKState?) { | |
| //movement.flame?.stateMachine.enter(FlameOnState.self) | |
| } | |
| override func isValidNextState(_ stateClass: AnyClass) -> Bool { | |
| stateClass is MovementIdleState.Type | |
| } | |
| } | |
| // MARK: - FLAME STATES (solo transiciones) | |
| final class FlameOffState: GKState { | |
| unowned let flame: ThrustFlameComponent | |
| init(_ flame: ThrustFlameComponent) { self.flame = flame } | |
| override func didEnter(from previousState: GKState?) { flame.setVisible(false) } | |
| override func isValidNextState(_ stateClass: AnyClass) -> Bool { | |
| stateClass is FlameOnState.Type || stateClass is FlameAfterburnState.Type | |
| } | |
| } | |
| final class FlameOnState: GKState { | |
| unowned let flame: ThrustFlameComponent | |
| init(_ flame: ThrustFlameComponent) { self.flame = flame } | |
| override func didEnter(from previousState: GKState?) { | |
| flame.setVisible(true) | |
| flame.setColor(.yellow) | |
| flame.stopAnimation() | |
| } | |
| override func isValidNextState(_ stateClass: AnyClass) -> Bool { | |
| stateClass is FlameOffState.Type || stateClass is FlameAfterburnState.Type | |
| } | |
| } | |
| final class FlameAfterburnState: GKState { | |
| unowned let flame: ThrustFlameComponent | |
| init(_ flame: ThrustFlameComponent) { self.flame = flame } | |
| override func didEnter(from previousState: GKState?) { | |
| flame.setVisible(true) | |
| flame.setColor(.orange) | |
| flame.pulseAnimation() | |
| } | |
| override func willExit(to nextState: GKState) { flame.stopAnimation() } | |
| override func isValidNextState(_ stateClass: AnyClass) -> Bool { | |
| stateClass is FlameOnState.Type || stateClass is FlameOffState.Type | |
| } | |
| } | |
| // MARK: - FLAME COMPONENT | |
| final class ThrustFlameComponent: GKComponent { | |
| let node: SKShapeNode | |
| lazy var stateMachine: GKStateMachine = { | |
| let sm = GKStateMachine(states: [ | |
| FlameOffState(self), | |
| FlameOnState(self), | |
| FlameAfterburnState(self) | |
| ]) | |
| sm.enter(FlameOffState.self) | |
| return sm | |
| }() | |
| override init() { | |
| node = SKShapeNode(rectOf: CGSize(width: 10, height: 14), cornerRadius: 3) | |
| node.name = "flame" | |
| node.fillColor = .yellow | |
| node.strokeColor = .clear | |
| node.isHidden = true | |
| super.init() | |
| _ = stateMachine | |
| } | |
| required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } | |
| // MARK: - Public API | |
| func attach(to shipNode: SKSpriteNode) { | |
| node.position = CGPoint(x: 0, y: -shipNode.size.height / 2 - 10) | |
| shipNode.addChild(node) | |
| } | |
| func update(from movement: MovementComponent) { | |
| switch movement.phase { | |
| case .idle: | |
| stateMachine.enter(FlameOffState.self) | |
| case .thrust(let intensity): | |
| if intensity > 0.6 { | |
| stateMachine.enter(FlameAfterburnState.self) | |
| } else { | |
| stateMachine.enter(FlameOnState.self) | |
| } | |
| } | |
| } | |
| // MARK: - Helpers | |
| func setVisible(_ visible: Bool) { node.isHidden = !visible } | |
| func setColor(_ color: SKColor) { node.fillColor = color } | |
| func pulseAnimation() { | |
| node.removeAllActions() | |
| let up = SKAction.scaleY(to: 1.5, duration: 0.1) | |
| let down = SKAction.scaleY(to: 1.0, duration: 0.1) | |
| node.run(.repeatForever(.sequence([up, down])), withKey: "pulse") | |
| } | |
| func stopAnimation() { | |
| node.removeAction(forKey: "pulse") | |
| node.setScale(1.0) | |
| } | |
| } | |
| // MARK: - MOVEMENT COMPONENT (toda la lógica aquí) | |
| final class MovementComponent: GKComponent { | |
| var velocity = CGVector.zero | |
| var inputDirection: CGPoint = .zero | |
| let acceleration: CGFloat = 100 | |
| let maxSpeed: CGFloat = 200 | |
| let idleFriction: CGFloat = 0.98 | |
| let movingFriction: CGFloat = 0.98 | |
| let minSpeedBeforeStop: CGFloat = 2.0 | |
| lazy var stateMachine: GKStateMachine = { | |
| let sm = GKStateMachine(states: [ | |
| MovementIdleState(self), | |
| MovementMovingState(self), | |
| ]) | |
| sm.enter(MovementIdleState.self) | |
| return sm | |
| }() | |
| override init() { | |
| super.init() | |
| _ = stateMachine | |
| } | |
| required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } | |
| // MARK: - Input | |
| func setInput(_ dir: CGPoint) { inputDirection = dir } | |
| // MARK: - Estado expuesto (para flame u otros) | |
| enum Phase { | |
| case idle | |
| case thrust(intensity: CGFloat) | |
| } | |
| var phase: Phase { | |
| let dx = inputDirection.x | |
| let dy = inputDirection.y | |
| let isMovingUp = dy > 0 && abs(dy) > abs(dx) | |
| let magnitude = sqrt(dx * dx + dy * dy) | |
| return if isMovingUp { | |
| .thrust(intensity: min(magnitude, 1)) | |
| } else { | |
| .idle | |
| } | |
| } | |
| // var phase: Phase { | |
| // if inputMagnitude < 0.01 { | |
| // return .idle | |
| // } else { | |
| // return .thrust(intensity: min(inputMagnitude, 1)) | |
| // } | |
| // } | |
| // MARK: - Update | |
| func update(node: SKNode, deltaTime: TimeInterval) { | |
| let dt = CGFloat(deltaTime) | |
| let dx = inputDirection.x | |
| let dy = inputDirection.y | |
| let magnitude = sqrt(dx * dx + dy * dy) | |
| if magnitude > 0.01 { | |
| // Movimiento activo | |
| stateMachine.enter(MovementMovingState.self) | |
| let normalized = CGPoint(x: dx / magnitude, y: dy / magnitude) | |
| let intensity = min(magnitude, 1.0) | |
| velocity.dx += normalized.x * acceleration * intensity * dt | |
| velocity.dy += normalized.y * acceleration * intensity * dt | |
| // Limitar velocidad | |
| let speed = velocity.length | |
| if speed > maxSpeed { | |
| let ratio = maxSpeed / speed | |
| velocity.dx *= ratio | |
| velocity.dy *= ratio | |
| } | |
| } else { | |
| // Inercia y frenado suave | |
| if velocity.length > minSpeedBeforeStop { | |
| velocity.dx *= idleFriction | |
| velocity.dy *= idleFriction | |
| } else { | |
| velocity = .zero | |
| stateMachine.enter(MovementIdleState.self) | |
| } | |
| } | |
| // Aplicar movimiento | |
| node.position.x += velocity.dx * dt | |
| node.position.y += velocity.dy * dt | |
| } | |
| } | |
| // MARK: - RENDER + CONSTRAINT | |
| final class RenderComponent: GKComponent { | |
| let node: SKSpriteNode | |
| init(textureName: String, z: CGFloat = 0) { | |
| let tex = SKTexture(imageNamed: textureName) | |
| tex.filteringMode = .nearest | |
| node = SKSpriteNode(texture: tex) | |
| node.zPosition = z | |
| super.init() | |
| } | |
| required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } | |
| } | |
| final class ConstraintComponent: GKComponent { | |
| private var applied = false | |
| private let margin: CGFloat | |
| init(margin: CGFloat = 0) { self.margin = margin; super.init() } | |
| required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } | |
| func apply(to node: SKNode, in size: CGSize) { | |
| guard !applied else { return } | |
| let hw = node.frame.width / 2 | |
| let hh = node.frame.height / 2 | |
| let xRange = SKRange(lowerLimit: hw + margin, upperLimit: size.width - hw - margin) | |
| let yRange = SKRange(lowerLimit: hh + margin, upperLimit: size.height - hh - margin) | |
| node.constraints = [SKConstraint.positionX(xRange, y: yRange)] | |
| applied = true | |
| } | |
| } | |
| // MARK: - SHIP ENTITY | |
| final class ShipEntity: GKEntity { | |
| let flame: ThrustFlameComponent | |
| let movement: MovementComponent | |
| init(position: CGPoint) { | |
| let render = RenderComponent(textureName: "space_ship", z: 2) | |
| render.node.position = position | |
| render.node.setScale(2.0) | |
| flame = ThrustFlameComponent() | |
| movement = MovementComponent() | |
| let constraint = ConstraintComponent() | |
| super.init() | |
| addComponent(render) | |
| addComponent(movement) | |
| addComponent(flame) | |
| addComponent(constraint) | |
| flame.attach(to: render.node) | |
| } | |
| required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } | |
| var node: SKSpriteNode { component(ofType: RenderComponent.self)!.node } | |
| } | |
| // MARK: - SCENE | |
| final class ECSShipScene: SKScene { | |
| private var entities = [GKEntity]() | |
| private var lastUpdate: TimeInterval = 0 | |
| private var ship: ShipEntity? | |
| override func didMove(to view: SKView) { | |
| let bg = SKSpriteNode(imageNamed: "bg_space") | |
| bg.size = size | |
| bg.position = CGPoint(x: size.width/2, y: size.height/2) | |
| addChild(bg) | |
| let s = ShipEntity(position: CGPoint(x: size.width/2, y: size.height/2)) | |
| addChild(s.node) | |
| entities.append(s) | |
| ship = s | |
| } | |
| func move(_ dir: CGPoint) { ship?.movement.setInput(dir) } | |
| override func update(_ currentTime: TimeInterval) { | |
| var dt = currentTime - lastUpdate | |
| if lastUpdate == 0 { dt = 0 } | |
| lastUpdate = currentTime | |
| for e in entities { | |
| guard let render = e.component(ofType: RenderComponent.self) else { continue } | |
| if let movement = e.component(ofType: MovementComponent.self) { | |
| movement.update(node: render.node, deltaTime: dt) | |
| if let flame = e.component(ofType: ThrustFlameComponent.self) { | |
| flame.update(from: movement) | |
| } | |
| } | |
| e.component(ofType: ConstraintComponent.self)?.apply(to: render.node, in: size) | |
| } | |
| } | |
| } | |
| // MARK: - SWIFTUI VIEW | |
| struct ECSShipDemoView: View { | |
| @State private var stick: 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]) | |
| .frame(width: 400, height: 400) | |
| .border(Color.blue) | |
| AnalogStickView(value: $stick) | |
| .frame(width: 180, height: 180) | |
| } | |
| .onChange(of: stick) { _, newValue in scene.move(newValue) } | |
| } | |
| } | |
| #Preview { | |
| ECSShipDemoView() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment