Last active
December 29, 2018 05:42
-
-
Save brookinc/2767ca8ca1963622a94f984e9267d8bf to your computer and use it in GitHub Desktop.
A simple Entity-Component System experiment in Swift.
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 Foundation | |
#if swift(>=4.2) | |
// no shim necessary | |
#else | |
// add shims for swift-4.2-style random(in:) methods | |
// NOTE: unlike Swift 4.2's implementations, these versions are prone to modulo bias, | |
// are not cryptographically secure, and don't support the full range of Int64 values | |
#if os(Linux) | |
srandom(UInt32(time(nil))) | |
func randomSystemInt() -> Int { | |
return SwiftGlibc.random() | |
} | |
#elseif os(macOS) | |
func randomSystemInt() -> Int { | |
return Int(arc4random()) | |
} | |
#endif | |
extension Int { | |
static func random(in range: Range<Int>) -> Int { | |
return Int(randomSystemInt() % (range.upperBound - range.lowerBound)) + range.lowerBound | |
} | |
static func random(in range: ClosedRange<Int>) -> Int { | |
return Int(randomSystemInt() % (range.upperBound - range.lowerBound + 1)) + range.lowerBound | |
} | |
} | |
extension Double { | |
static func random(in range: Range<Double>) -> Double { | |
return (Double(randomSystemInt()) / Double(Int32.max) * (range.upperBound - range.lowerBound)) + range.lowerBound | |
} | |
static func random(in range: ClosedRange<Double>) -> Double { | |
return (Double(randomSystemInt()) / Double(Int32.max - 1) * (range.upperBound - range.lowerBound)) + range.lowerBound | |
} | |
} | |
extension Bool { | |
static func random() -> Bool { | |
return (Int.random(in: 0 ... 1) == 0) | |
} | |
} | |
#endif | |
// system configuration variables | |
let numEntities = 5 | |
let entityMaxID = 100 | |
let coordinateRange = 10 | |
let numTicks = 8 | |
let componentProbability = 0.25 | |
// component configuration variables | |
let healthRange = 30.0 ... 100.0 | |
let healthDecay = 10.0 | |
let maxAttackStrength = 10.0 | |
/// The core class for entities. | |
class Entity { | |
/// The entity's ID value. | |
var id: Int | |
/// The entity's array of components | |
var components: [Component] = [] | |
init(id newId: Int) { | |
id = newId | |
} | |
func print() { | |
Swift.print("\(id)", terminator: "") | |
for component in components { | |
component.print() | |
} | |
Swift.print("") | |
} | |
func tick() { | |
for component in components { | |
component.tick() | |
} | |
} | |
func hasComponent<C: Component>(_ component: C.Type) -> Bool { | |
return getComponent(component.self) != nil | |
} | |
func getComponent<C: Component>(_: C.Type) -> C? { | |
for component in components { | |
if let validComponent = component as? C { | |
return validComponent | |
} | |
} | |
return nil | |
} | |
} | |
/// The base class for components. | |
class Component { | |
/// TODO: can swiftlint detect this strong reference cycle? | |
//let entity: Entity | |
/// The entity to which this component is attached (`unowned` to prevent a strong reference cycle). | |
unowned let entity: Entity | |
init(_ owner: Entity) { | |
entity = owner | |
} | |
func tick() { | |
} | |
func print() { | |
} | |
} | |
/// A component for entities with positions. | |
class PositionComponent: Component { | |
var pos = (x: 0, y: 0) { | |
didSet { | |
pos.x = min(max(pos.x, 0), coordinateRange) | |
pos.y = min(max(pos.y, 0), coordinateRange) | |
} | |
} | |
override func print() { | |
Swift.print(" [\(pos.x), \(pos.y)]", terminator: "") | |
} | |
func distSqFrom(pos: (Int, Int)) -> Int { | |
return (self.pos.x - pos.0) * (self.pos.x - pos.0) + (self.pos.y - pos.1) * (self.pos.y - pos.1) | |
} | |
} | |
/// A component to subject entities to gravity. | |
class GravityComponent: Component { | |
override func tick() { | |
entity.getComponent(PositionComponent.self)?.pos.y -= 1 | |
} | |
override func print() { | |
Swift.print(" g", terminator: "") | |
} | |
} | |
/// A component to make entities locomote randomly. | |
class RandomPropulsionComponent: Component { | |
override func tick() { | |
if Bool.random() { | |
entity.getComponent(PositionComponent.self)?.pos.x += Int.random(in: -1 ... 1) | |
} else { | |
entity.getComponent(PositionComponent.self)?.pos.y += Int.random(in: -1 ... 1) | |
} | |
} | |
override func print() { | |
Swift.print(" rp", terminator: "") | |
} | |
} | |
/// A component for entities that can be hurt/be healed/die. | |
class HealthComponent: Component { | |
let maxHealth: Double | |
private(set) var health: Double { | |
didSet { | |
health = min(max(health, 0.0), Double(maxHealth)) | |
} | |
} | |
init(_ owner: Entity, max: Double) { | |
maxHealth = max | |
health = max | |
super.init(owner) | |
} | |
override func tick() { | |
health -= healthDecay | |
/// TODO: when health is <= 0.0, die / deallocate? | |
} | |
override func print() { | |
Swift.print(" h=\(health)/\(maxHealth)", terminator: "") | |
} | |
func hurt(_ damage: Double) { | |
if damage > 0.0 { | |
health -= damage | |
} | |
} | |
func heal(_ restore: Double) { | |
if restore > 0.0 { | |
health += restore | |
} | |
} | |
} | |
/// A component that allows an entity to attack nearby entities. | |
class AttackNearbyComponent: Component { | |
private(set) var attackStrength: Double | |
init(_ owner: Entity, strength: Double) { | |
attackStrength = strength | |
super.init(owner) | |
} | |
override func tick() { | |
guard entity.hasComponent(PositionComponent.self) else { | |
return | |
} | |
/// TODO: find closest entity with a HealthComponent | |
/// TODO: determine attack success probability by distance | |
/// TODO: determine max attack damage by distance | |
} | |
override func print() { | |
Swift.print(" a=\(attackStrength)", terminator: "") | |
} | |
} | |
// spawn some entities at random positions | |
print("\nEntities:") | |
var entities: [Entity] = [] | |
for _ in 0 ..< numEntities { | |
let entity = Entity(id: Int.random(in: 1 ... entityMaxID)) | |
let posComponent = PositionComponent(entity) | |
posComponent.pos.x = Int.random(in: 0 ..< coordinateRange) | |
posComponent.pos.y = Int.random(in: 0 ..< coordinateRange) | |
entity.components.append(posComponent) | |
entity.print() | |
entities.append(entity) | |
} | |
// assign components to them randomly | |
print("\nComponents:") | |
for entity in entities { | |
if Double.random(in: 0.0 ... 1.0) < componentProbability { | |
entity.components.append(GravityComponent(entity)) | |
} | |
if Double.random(in: 0.0 ... 1.0) < componentProbability { | |
entity.components.append(RandomPropulsionComponent(entity)) | |
} | |
if Double.random(in: 0.0 ... 1.0) < componentProbability { | |
entity.components.append(HealthComponent(entity, max: Double.random(in: healthRange).rounded())) | |
} | |
if Double.random(in: 0.0 ... 1.0) < componentProbability { | |
entity.components.append(AttackNearbyComponent(entity, strength: Double.random(in: 0.0 ... maxAttackStrength).rounded())) | |
} | |
} | |
for entity in entities { | |
if entity.hasComponent(GravityComponent.self) { | |
print("Entity \(entity.id) has a GravityComponent") | |
} | |
if entity.hasComponent(RandomPropulsionComponent.self) { | |
print("Entity \(entity.id) has a RandomPropulsionComponent") | |
} | |
if entity.hasComponent(HealthComponent.self) { | |
print("Entity \(entity.id) has a HealthComponent") | |
} | |
if entity.hasComponent(AttackNearbyComponent.self) { | |
print("Entity \(entity.id) has a AttackNearbyComponent") | |
} | |
} | |
// simulate their interactions | |
print("\nInitial:") | |
for entity in entities { | |
entity.print() | |
} | |
for tickNum in 0 ..< numTicks { | |
for entity in entities { | |
entity.tick() | |
} | |
print("\nAfter Frame \(tickNum):") | |
for entity in entities { | |
entity.print() | |
} | |
} | |
/// TODO: parse entities and components from JSON | |
/// TODO: serialize / deserialize (JSON? binary?) | |
/// TODO: move health depletion into a PoisonComponent | |
/// TODO: convert entities from Array to Dictionary (with ID as index) |
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
# force linefeed (Unix-style) line endings for all .swift files, to prevent SwiftLint | |
# vertical_whitespace warnings (and line number mismatches) on Windows host machines | |
*.swift eol=lf |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment