Last active
November 26, 2020 08:55
-
-
Save dduan/ed77683dcc9b1a52f533d17f266aa769 to your computer and use it in GitHub Desktop.
A rotating 3-D cube in terminal. Written in Swift
This file contains 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
/// A rotating 3-D cube in terminal | |
/// Only works on macOS | |
/// Run `swift cube.swift` in a terminal application to run it. | |
/// For controlling the cube, see comments for `Key` in code. | |
import Darwin | |
enum RawModeError: Error { | |
case notATerminal | |
case failedToGetTerminalSetting | |
case failedToSetTerminalSetting | |
} | |
func runInRawMode(_ task: @escaping () throws -> Void) throws { | |
var originalTermSetting = termios() | |
guard isatty(STDIN_FILENO) != 0 else { | |
throw RawModeError.notATerminal | |
} | |
guard tcgetattr(STDIN_FILENO, &originalTermSetting) >= 0 else { | |
throw RawModeError.failedToGetTerminalSetting | |
} | |
var raw = originalTermSetting | |
raw.c_iflag &= ~(UInt(BRKINT) | UInt(ICRNL) | UInt(INPCK) | UInt(ISTRIP) | UInt(IXON)) | |
raw.c_oflag &= ~(UInt(OPOST)) | |
raw.c_cflag |= UInt(CS8) | |
raw.c_lflag &= ~(UInt(ECHO) | UInt(ICANON) | UInt(IEXTEN) | UInt(ISIG)) | |
raw.c_cc.16 = 0 | |
raw.c_cc.17 = 1 | |
guard tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) >= 0 else { | |
throw RawModeError.failedToSetTerminalSetting | |
} | |
defer { | |
tcsetattr(STDIN_FILENO, TCSAFLUSH, &originalTermSetting) | |
print("\u{1b}[?25h", terminator: "") | |
print("\u{1b}[0;0H") | |
} | |
print("\u{1b}[2J") | |
print("\u{1b}[?25l") | |
try task() | |
} | |
enum Key { | |
static let q = Character("q").asciiValue! // quit | |
static let d = Character("d").asciiValue! // show debug info | |
static let j = Character("j").asciiValue! // increase yaw | |
static let k = Character("k").asciiValue! // decrease yaw | |
static let x = Character("x").asciiValue! // increase pitch | |
static let b = Character("b").asciiValue! // decrease pitch | |
static let m = Character("m").asciiValue! // increase roll | |
static let w = Character("w").asciiValue! // decrease roll | |
static let r = Character("r").asciiValue! // reset | |
} | |
final class Graphic { | |
var buffer: [Character] | |
let width: Int | |
let height: Int | |
var clearedBuffer: [Character] | |
init(width: Int, height: Int) { | |
self.width = width | |
self.height = height | |
clearedBuffer = .init(repeating: " ", count: width * height) | |
buffer = clearedBuffer | |
} | |
subscript(x: Int, y: Int) -> Character { | |
get { | |
let index = y * width + x | |
assert(index < buffer.count) | |
return buffer[index] | |
} | |
set { | |
let index = y * width + x | |
guard index >= 0 && index < buffer.count else { return } | |
buffer[index] = newValue | |
} | |
} | |
func clear() { | |
buffer = clearedBuffer | |
} | |
} | |
extension timeval { | |
func microseconds(since other: timeval) -> Double { | |
Double(self.tv_usec - other.tv_usec) / 1000 + Double(self.tv_sec - other.tv_sec) * 1000 | |
} | |
} | |
class Game { | |
let graphic: Graphic | |
let fps: Double | |
let msPerRender: Double | |
var lastRender = timeval() | |
var showDebugInfo = false | |
var loopCount = 0 | |
var exit = false | |
var now: timeval { | |
var result = timeval() | |
gettimeofday(&result, nil) | |
return result | |
} | |
init(graphicWidth: Int, graphicHeight: Int, fps: Double = 60) { | |
self.graphic = .init(width: graphicWidth, height: graphicHeight) | |
self.fps = fps | |
self.msPerRender = 1 / fps | |
} | |
func positionAt(x: Int, y: Int) { | |
print("\u{1b}[\(y);\(x)H", terminator: "") | |
} | |
func run() throws { | |
try runInRawMode { [weak self] in | |
guard let self = self else { return } | |
while !self.exit { | |
var input: UInt8 = 0 | |
read(STDIN_FILENO, &input, 1) | |
self.handle(input: input) | |
self.update() | |
let now = self.now | |
let msSince = now.microseconds(since: self.lastRender) | |
if msSince >= self.msPerRender { | |
self.lastRender = now | |
self.render() | |
self.positionAt(x: 0, y: 0) | |
for line in 0 ..< self.graphic.height { | |
let start = line * self.graphic.width | |
let end = start + self.graphic.width | |
self.positionAt(x: 0, y: line) | |
print(String(self.graphic.buffer[start ..< end])) | |
} | |
if self.showDebugInfo { | |
self.positionAt(x: 1, y: 1) | |
let lps = Int(Double(self.loopCount) / (1 / msSince)) | |
print("w: \(self.graphic.width) h: \(self.graphic.height) lps: \(lps)") | |
self.loopCount = 0 | |
} | |
} | |
self.loopCount += 1 | |
} | |
} | |
print("\u{1b}[2J") | |
} | |
open func handle(input: UInt8) {} | |
open func update() {} | |
open func render() {} | |
} | |
struct Vec3 { | |
let x, y, z: Float | |
static func + (lhs: Self, rhs: Self) -> Self { | |
.init(x: lhs.x + rhs.x, y: lhs.y + rhs.y, z: lhs.z + rhs.z) | |
} | |
static func * (lhs: Self, rhs: Float) -> Self { | |
.init(x: lhs.x * rhs, y: lhs.y * rhs, z: lhs.z * rhs) | |
} | |
} | |
struct Cube { | |
var size: Float = 1 | |
var scanFrequency: Float = 0.1 | |
// (texture for the surface, vector perpendicular to the surfaces, points on the surface) | |
var surfaces: [(Character, Vec3, [Vec3])] { | |
[(Character, Vec3, Vec3, Vec3, Vec3)]([ | |
("█", .init(x: 0, y: 0, z: -1), .init(x: -0.5, y: -0.5, z: -0.5), .init(x: 1, y: 0, z: 0), .init(x: 0, y: 1, z: 0)), | |
("▓", .init(x: 0, y: 1, z: 0), .init(x: -0.5, y: 0.5, z: -0.5), .init(x: 1, y: 0, z: 0), .init(x: 0, y: 0, z: 1)), | |
("▒", .init(x: 0, y: 0, z: 1), .init(x: -0.5, y: 0.5, z: 0.5), .init(x: 1, y: 0, z: 0), .init(x: 0, y: -1, z: 0)), | |
("░", .init(x: 0, y: -1, z: 0), .init(x: -0.5, y: -0.5, z: 0.5), .init(x: 1, y: 0, z: 0), .init(x: 0, y: 0, z: -1)), | |
("▦", .init(x: 1, y: 0, z: 0), .init(x: 0.5, y: -0.5, z: -0.5), .init(x: 0, y: 0, z: 1), .init(x: 0, y: 1, z: 0)), | |
("▧", .init(x: -1, y: 0, z: 0), .init(x: -0.5, y: 0.5, z: -0.5), .init(x: 0, y: 0, z: 1), .init(x: 0, y: -1, z: 0)), | |
]) | |
.map { texture, perp, start, d1, d2 in | |
( | |
texture, | |
perp, | |
stride(from: 0, to: 1, by: scanFrequency) | |
.flatMap { d2Offset in | |
stride(from: 0, to: 1, by: scanFrequency) | |
.map { d1Offset in | |
(start + d1 * d1Offset + d2 * d2Offset) * size | |
} | |
} | |
) | |
} | |
} | |
} | |
final class CubeGame: Game { | |
var cube: Cube | |
var yaw: Float = 0 | |
var pitch: Float = 0 | |
var roll: Float = 0 | |
init() { | |
cube = .init(size: 18, scanFrequency: 0.04) | |
super.init(graphicWidth: 50, graphicHeight: 50) | |
} | |
override func handle(input: UInt8) { | |
switch input { | |
case Key.q: | |
self.exit = true | |
case Key.d: | |
self.showDebugInfo.toggle() | |
case Key.j: | |
self.yaw += 0.1 | |
case Key.k: | |
self.yaw -= 0.1 | |
case Key.x: | |
self.roll += 0.1 | |
case Key.b: | |
self.roll -= 0.1 | |
case Key.m: | |
self.pitch += 0.1 | |
case Key.w: | |
self.pitch -= 0.1 | |
case Key.r: | |
self.pitch = 0 | |
self.yaw = 0 | |
self.roll = 0 | |
default: | |
break | |
} | |
} | |
// Apply standard rotation matrix | |
func rotate(vec: Vec3) -> Vec3 { | |
let x = vec.x | |
let y = vec.y | |
let z = vec.z | |
let cosYaw = cos(yaw) | |
let sinYaw = sin(yaw) | |
let cosPitch = cos(pitch) | |
let sinPitch = sin(pitch) | |
let cosRoll = cos(roll) | |
let sinRoll = sin(roll) | |
let rotatedX = cosYaw * cosPitch * x + (cosYaw * sinPitch * sinRoll - sinYaw * cosRoll) * y + (cosYaw * sinPitch * cosRoll + sinYaw * sinRoll) * z | |
let rotatedY = sinYaw * cosPitch * x + (sinYaw * sinPitch * sinRoll + cosYaw * cosRoll) * y + (sinYaw * sinPitch * cosRoll - cosYaw * sinRoll) * z | |
let rotatedZ = -sinPitch * x + cosPitch * sinRoll * y + cosPitch * cosRoll * z | |
return .init(x: rotatedX, y: rotatedY, z: rotatedZ) | |
} | |
override func render() { | |
graphic.clear() | |
for (texture, perp, points) in cube.surfaces { | |
// if an vector perpendicular to a surface has negative dot product with one such vector that points towards the view point, it is perpendicular to a hidden surface. | |
let rotatedPerp = rotate(vec: perp) | |
if -rotatedPerp.z <= 0 { | |
continue | |
} | |
for point in points { | |
let rotated = rotate(vec: point) | |
// coordinates need to be shifted to the middle of the view port | |
graphic[Int(rotated.x.rounded(.down)) + graphic.width / 2, Int(rotated.y.rounded(.down)) + graphic.height / 2 + 1] = texture | |
} | |
} | |
} | |
} | |
try CubeGame().run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Demo: