Skip to content

Instantly share code, notes, and snippets.

@Codelaby
Created November 12, 2025 14:00
Show Gist options
  • Select an option

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

Select an option

Save Codelaby/b53177a25587c070652f65466057a933 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: - 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