Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
450 changes: 450 additions & 0 deletions ios/GamerEmulator/GamerEmulator.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"images" : [
{ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
6 changes: 6 additions & 0 deletions ios/GamerEmulator/GamerEmulator/Assets.xcassets/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
15 changes: 15 additions & 0 deletions ios/GamerEmulator/GamerEmulator/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// ContentView.swift — App root view.
// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus
// SPDX-License-Identifier: MIT

import SwiftUI

struct ContentView: View {
var body: some View {
GamerView()
}
}

#Preview {
ContentView()
}
129 changes: 129 additions & 0 deletions ios/GamerEmulator/GamerEmulator/GameAssets/GameAssets.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// GameAssets.swift — All bitmap / melody data ported from progmem_assets.h
// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus
// SPDX-License-Identifier: MIT

import Foundation

// MARK: - Note constants (OCR2A register values; frequency ≈ 1,000,000 / (n+1) Hz)
let NOTE_B7: Int = 252 // ~3937 Hz
let NOTE_C8: Int = 238 // ~4202 Hz
let NOTE_D8: Int = 212 // ~4717 Hz
let NOTE_E8: Int = 189 // ~5263 Hz
let NOTE_G8: Int = 158 // ~6329 Hz
let NOTE_A8: Int = 140 // ~7042 Hz
let NOTE_B8: Int = 125 // ~7812 Hz

enum GameAssets {

// MARK: - Launcher animation frames (8 rows per frame, MSB = column 0)

static let startup: [[UInt8]] = [
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
]

/// Snake: frame 0 = S-curve + food dot; frame 1 = food dot off
static let snake: [[UInt8]] = [
[0x00, 0x38, 0x08, 0x0E, 0x00, 0x40, 0x00, 0x00],
[0x00, 0x38, 0x08, 0x0E, 0x00, 0x00, 0x00, 0x00]
]

/// Breakout: frame 0 = blocks + ball + paddle; frame 1 = one block missing
static let breakout: [[UInt8]] = [
[0xFF, 0xFF, 0x00, 0x10, 0x00, 0x00, 0x00, 0x38],
[0xFF, 0xDF, 0x00, 0x00, 0x10, 0x00, 0x00, 0x38]
]

/// Simon: frame 0 = TL+BR lit; frame 1 = TR+BL lit
static let simon: [[UInt8]] = [
[0xF0, 0xF0, 0xF0, 0xF0, 0x0F, 0x0F, 0x0F, 0x0F],
[0x0F, 0x0F, 0x0F, 0x0F, 0xF0, 0xF0, 0xF0, 0xF0]
]

/// Flappy: frame 0 = wing up; frame 1 = wing down
static let flappy: [[UInt8]] = [
[0x01, 0x01, 0x40, 0x60, 0x00, 0x00, 0x01, 0x01],
[0x01, 0x01, 0x00, 0x60, 0x40, 0x00, 0x01, 0x01]
]

/// Tetris: O-piece falling
static let tetris: [[UInt8]] = [
[0x30, 0x30, 0x00, 0x00, 0x00, 0x00, 0xCF, 0xFF],
[0x00, 0x30, 0x30, 0x00, 0x00, 0x00, 0xCF, 0xFF]
]

/// Alien: two-frame walk cycle
static let alien: [[UInt8]] = [
[0x00, 0x00, 0x7E, 0x5A, 0x7E, 0x24, 0x24, 0x66],
[0x00, 0x7E, 0x5A, 0x7E, 0x24, 0x42, 0xC3, 0x00]
]

/// Conway: glider two-step
static let conway: [[UInt8]] = [
[0x40, 0x20, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00],
[0xA0, 0x60, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00]
]

/// Dino: standing + cactus / jumping over cactus
static let dino: [[UInt8]] = [
[0x00, 0x00, 0x00, 0x00, 0x00, 0x44, 0x44, 0xFF],
[0x00, 0x00, 0x00, 0x40, 0x40, 0x04, 0x04, 0xFF]
]

/// Brightness: sun outline / sun filled
static let brightness: [[UInt8]] = [
[0x00, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x00],
[0x00, 0x66, 0x3C, 0x7E, 0x7E, 0x3C, 0x66, 0x00]
]

// MARK: - In-game images

/// Breakout win/lose face
static let breakoutFaces: [[UInt8]] = [
[0x00, 0x66, 0x66, 0x00, 0x42, 0x3C, 0x00, 0x00], // win
[0x00, 0x66, 0x66, 0x00, 0x00, 0x3C, 0x42, 0x00] // lose
]

/// Simon arrows: [0]=up [1]=down [2]=left [3]=right
static let simonArrows: [[UInt8]] = [
[0x00, 0x18, 0x3C, 0x7E, 0x18, 0x18, 0x18, 0x00], // up
[0x00, 0x18, 0x18, 0x18, 0x7E, 0x3C, 0x18, 0x00], // down
[0x00, 0x10, 0x30, 0x7E, 0x7E, 0x30, 0x10, 0x00], // left
[0x00, 0x08, 0x0C, 0x7E, 0x7E, 0x0C, 0x08, 0x00] // right
]

/// Simon "GO"
static let simonGo: [UInt8] = [
0x00, 0x6E, 0x8A, 0x8A, 0x8A, 0xAA, 0x6E, 0x20
]

/// Simon "WRONG" (X)
static let simonWrong: [UInt8] = [
0xC3, 0x66, 0x3C, 0x18, 0x18, 0x3C, 0x66, 0xC3
]

/// Simon "RIGHT" (checkmark)
static let simonRight: [UInt8] = [
0x01, 0x03, 0x07, 0x0E, 0xDC, 0xF8, 0x70, 0x20
]

// MARK: - Score digit bitmaps (3 pixels wide; left-shift 5 for tens position)
static let numbers: [[UInt8]] = [
[0x07, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x07], // 0
[0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], // 1
[0x07, 0x01, 0x01, 0x07, 0x04, 0x04, 0x04, 0x07], // 2
[0x07, 0x01, 0x01, 0x03, 0x01, 0x01, 0x01, 0x07], // 3
[0x05, 0x05, 0x05, 0x07, 0x01, 0x01, 0x01, 0x01], // 4
[0x07, 0x04, 0x04, 0x07, 0x01, 0x01, 0x01, 0x07], // 5
[0x07, 0x04, 0x04, 0x07, 0x05, 0x05, 0x05, 0x07], // 6
[0x07, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01], // 7
[0x07, 0x05, 0x05, 0x07, 0x05, 0x05, 0x05, 0x07], // 8
[0x07, 0x05, 0x05, 0x07, 0x01, 0x01, 0x01, 0x07] // 9
]

// MARK: - Tetris Korobeiniki melody (OCR2A note values, 16 notes)
/// Playback: one note per 200 ms, looping.
static let tetrisMelody: [Int] = [
189, 252, 238, 212, 212, 238, 252, 238,
189, 140, 140, 238, 189, 212, 238, 252
]
}
24 changes: 24 additions & 0 deletions ios/GamerEmulator/GamerEmulator/GamerEmulatorApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// GamerEmulatorApp.swift — SwiftUI application entry point.
// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus
// SPDX-License-Identifier: MIT

import SwiftUI

@main
struct GamerEmulatorApp: App {

init() {
// First-launch high-score initialisation (mirrors Arduino EEPROM init)
let key = "highscores_initialised"
if !UserDefaults.standard.bool(forKey: key) {
HighScoreStore.resetAll()
UserDefaults.standard.set(true, forKey: key)
}
}

var body: some Scene {
WindowGroup {
ContentView()
}
}
}
124 changes: 124 additions & 0 deletions ios/GamerEmulator/GamerEmulator/Games/AlienGame.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// AlienGame.swift — Faithful port of src/games/alien.h
// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus
// SPDX-License-Identifier: MIT

import Foundation

@MainActor
final class AlienGame: Game {

let name = "ALIEN"
let gameIndex = 5
let animFrames = GameAssets.alien

// ── State ──────────────────────────────────────────────────────────────────
private var gameGoing: Bool = false
private var lastMove: Double = 0
private var moveDelay: Int = 800
private var alienLineCount: Int = 0
private var score: Int = 0
private var playerX: Int = 3

// ── Game protocol ──────────────────────────────────────────────────────────

func reset(fromHighScore: Bool, startingScore: UInt8) {
if fromHighScore && startingScore > 0 {
score = Int(startingScore) / 2
alienLineCount = score * 10 - 1
moveDelay = max(330, 800 - score * 5)
} else {
score = 0
alienLineCount = 0
moveDelay = 800
}
playerX = 3
lastMove = 0
gameGoing = true
}

func loop(gamer: GamerHardware) async {
if gameGoing {
await moveAliens(gamer: gamer)

// Inner wait loop: handle input during move delay
while gamer.millis() - lastMove < Double(moveDelay) {
await gamer.delay(10)
gamer.updateLEDFlash()
if gamer.soundEnabled { gamer.stopTone() }

if gamer.isPressed(.up) {
// Shoot upward
for i in stride(from: 7, through: 0, by: -1) {
gamer.display[playerX][i] = 1
await gamer.delay(20)
renderPlayer(gamer: gamer)
gamer.updateDisplay()
}
for i in stride(from: 7, through: 0, by: -1) {
gamer.display[playerX][i] = 0
await gamer.delay(20)
renderPlayer(gamer: gamer)
gamer.updateDisplay()
}
lastMove += 200
}
if gamer.isPressed(.left) && playerX > 0 {
playerX -= 1; renderPlayer(gamer: gamer); gamer.updateDisplay()
}
if gamer.isPressed(.right) && playerX < 7 {
playerX += 1; renderPlayer(gamer: gamer); gamer.updateDisplay()
}
}
} else {
// Game over state
if gamer.isPressed(.up) || gamer.isPressed(.left) || gamer.isPressed(.right) {
reset(fromHighScore: false, startingScore: 0)
} else {
gamer.showScore(score / 10, score % 10)
await gamer.delay(1000)
reset(fromHighScore: false, startingScore: 0)
}
}
}

// MARK: - Helpers

private func renderPlayer(gamer: GamerHardware) {
for i in 0..<8 { gamer.display[i][7] = 0; gamer.display[i][6] = 0 }
gamer.display[playerX][7] = 1
gamer.display[playerX][6] = 1
if playerX > 0 { gamer.display[playerX - 1][7] = 1 }
if playerX < 7 { gamer.display[playerX + 1][7] = 1 }
}

private func generateAlienRow(gamer: GamerHardware) {
for i in 0..<8 {
gamer.display[i][0] = Int.random(in: 0..<(moveDelay > 330 ? 3 : 4)) > 1 ? 1 : 0
}
}

private func moveAliens(gamer: GamerHardware) async {
// Check for loss: alien in row 5
for i in 0..<8 {
if gamer.display[i][5] == 1 {
gameGoing = false
HighScoreStore.saveHighScore(score, gameIndex)
return
}
}
// Shift rows down
for i in 0..<8 {
for j in stride(from: 5, through: 1, by: -1) {
gamer.display[i][j] = gamer.display[i][j - 1]
}
}
generateAlienRow(gamer: gamer)
renderPlayer(gamer: gamer)
gamer.updateDisplay()
lastMove = gamer.millis()

alienLineCount += 1
if alienLineCount % 10 == 0 { score += 1 }
if moveDelay > 300 { moveDelay -= 5 }
}
}
Loading