Skip to content

Instantly share code, notes, and snippets.

@Codelaby
Created November 12, 2025 17:57
Show Gist options
  • Select an option

  • Save Codelaby/707f08264ccd96533c19a0f7d8ce87ec to your computer and use it in GitHub Desktop.

Select an option

Save Codelaby/707f08264ccd96533c19a0f7d8ce87ec to your computer and use it in GitHub Desktop.
Learn the fundamentals of ECS (Entity-Component-System) of GameplayKit
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