Swift bindings to the Playdate C API
Wraps all ten subsystems of the Playdate SDK 3.1.1 C API (system, display, graphics, sprites, sound, file, JSON, Lua, scoreboards, network) in idiomatic Swift: namespaced APIs, wrapper types with ownership semantics, closures for callbacks, and typed throws. Written within the Embedded Swift subset so the same code can target the device. The SDK headers are resolved through a "playdate" pkg-config module (Scripts/install-pkgconfig.sh), so the package works as a normal SwiftPM dependency without unsafe build flags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "play-date"
|
||||
BuildableName = "play-date"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PlayDateTests"
|
||||
BuildableName = "PlayDateTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PlayDateTests"
|
||||
BuildableName = "PlayDateTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "No">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "play-date"
|
||||
BuildableName = "play-date"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,25 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Javier Cicchelli
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers the Swift bindings in this repository only. The Playdate
|
||||
SDK and its C API headers are the property of Panic, Inc. and are licensed
|
||||
separately; they are not distributed with this package.
|
||||
@@ -0,0 +1,39 @@
|
||||
// swift-tools-version: 6.4
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "play-date",
|
||||
products: [
|
||||
.library(
|
||||
name: "PlayDate",
|
||||
targets: ["PlayDate"]
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
// The Playdate C API headers, resolved through the "playdate"
|
||||
// pkg-config module. Run Scripts/install-pkgconfig.sh once to point
|
||||
// it at your Playdate SDK installation.
|
||||
.systemLibrary(
|
||||
name: "CPlaydate",
|
||||
path: "Sources/CPlaydate",
|
||||
pkgConfig: "playdate"
|
||||
),
|
||||
.target(
|
||||
name: "PlayDate",
|
||||
dependencies: ["CPlaydate"],
|
||||
path: "Sources/PlayDate",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("ApproachableConcurrency"),
|
||||
],
|
||||
),
|
||||
.testTarget(
|
||||
name: "PlayDateTests",
|
||||
dependencies: ["PlayDate"],
|
||||
path: "Tests/PlayDate",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("ApproachableConcurrency"),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,352 @@
|
||||
# PlayDate
|
||||
|
||||
Swift bindings to the [Playdate](https://play.date) C API.
|
||||
|
||||
The Playdate C API is delivered as a `PlaydateAPI*` struct of function
|
||||
pointers that the firmware hands to your game at launch. This package wraps
|
||||
that surface in idiomatic Swift: namespaced APIs, wrapper types with
|
||||
ownership semantics, closures instead of function-pointer/userdata pairs,
|
||||
`OptionSet`s and `enum`s instead of raw constants, and typed `throws` for
|
||||
fallible calls.
|
||||
|
||||
All ten C subsystems are covered:
|
||||
|
||||
| Namespace | Wraps | Highlights |
|
||||
|---|---|---|
|
||||
| `Playdate.System` | `playdate->system` | input, time, menu items, logging |
|
||||
| `Playdate.Display` | `playdate->display` | refresh rate, scale, mosaic, flip |
|
||||
| `Playdate.Graphics` | `playdate->graphics` | drawing, `Bitmap`, `Font`, `TileMap`, video |
|
||||
| `Playdate.Sprite` | `playdate->sprite` | display list, collisions, custom draw |
|
||||
| `Playdate.Sound` | `playdate->sound` | players, synths, sequences, effects |
|
||||
| `Playdate.File` | `playdate->file` | `Handle`, directory operations |
|
||||
| `Playdate.JSON` | `playdate->json` | `Value` tree decode/encode |
|
||||
| `Playdate.Lua` | `playdate->lua` | C functions, classes, stack access |
|
||||
| `Playdate.Scoreboards` | `playdate->scoreboards` | online leaderboards |
|
||||
| `Playdate.Network` | `playdate->network` | wifi, `HTTPConnection`, `TCPConnection` |
|
||||
|
||||
## Requirements
|
||||
|
||||
- The [Playdate SDK](https://play.date/dev/) (3.1.1 or later). The SDK is
|
||||
not vendored into this repository.
|
||||
- Swift 6.4 tools or later.
|
||||
|
||||
### One-time SDK setup
|
||||
|
||||
The `CPlaydate` target resolves `pd_api.h` through a `playdate` pkg-config
|
||||
module, so the package works as a normal SwiftPM dependency without unsafe
|
||||
build flags. Generate the pkg-config file once per machine:
|
||||
|
||||
```sh
|
||||
Scripts/install-pkgconfig.sh
|
||||
```
|
||||
|
||||
The script locates the SDK via `PLAYDATE_SDK_PATH` (default
|
||||
`~/Developer/PlaydateSDK`) and writes `playdate.pc` into a directory SwiftPM
|
||||
searches by default (`/usr/local/lib/pkgconfig`). Pass a custom destination
|
||||
as an argument if you prefer another location on your `PKG_CONFIG_PATH`.
|
||||
|
||||
If Xcode had the package open before you ran the script, make it re-read the
|
||||
manifest (File ▸ Packages ▸ Reset Package Caches) — Xcode caches package
|
||||
resolution and won't notice the new `.pc` file on its own.
|
||||
|
||||
## Adding the dependency
|
||||
|
||||
```swift
|
||||
// Package.swift of your game
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/<you>/play-date.git", from: "0.1.0"),
|
||||
// …or, while developing locally:
|
||||
// .package(path: "../play-date"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "MyGame",
|
||||
dependencies: [.product(name: "PlayDate", package: "play-date")]
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
||||
A Playdate game has a single C entry point, `eventHandler`. Export it with
|
||||
`@_cdecl`, initialize the binding on the first event, and install an update
|
||||
callback:
|
||||
|
||||
```swift
|
||||
import CPlaydate
|
||||
import PlayDate
|
||||
|
||||
@_cdecl("eventHandler")
|
||||
func eventHandler(pointer: UnsafeMutableRawPointer,
|
||||
event: PDSystemEvent,
|
||||
argument: UInt32) -> Int32 {
|
||||
switch Playdate.SystemEvent(event: event, argument: argument) {
|
||||
case .initialize:
|
||||
Playdate.initialize(with: pointer) // must happen before anything else
|
||||
Game.shared.start()
|
||||
case .pause:
|
||||
Game.shared.pause()
|
||||
default:
|
||||
break
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
final class Game {
|
||||
nonisolated(unsafe) static let shared = Game()
|
||||
|
||||
var player = Playdate.Sprite()
|
||||
|
||||
func start() {
|
||||
Playdate.Display.setRefreshRate(50)
|
||||
|
||||
Playdate.System.setUpdateCallback {
|
||||
self.update()
|
||||
return true // true = redraw the display this frame
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
let (_, pushed, _) = Playdate.System.buttonState
|
||||
if pushed.contains(.a) {
|
||||
Playdate.System.log("A pressed at \(Playdate.System.currentTimeMilliseconds)ms")
|
||||
}
|
||||
|
||||
Playdate.Sprite.updateAndDrawAll()
|
||||
Playdate.System.drawFPS()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Playdate.initialize(with:)` stores the API pointer once; every wrapper in
|
||||
the module uses it from then on. Calling any wrapper before `initialize` is
|
||||
a programmer error and will crash.
|
||||
|
||||
## Tour of the API
|
||||
|
||||
### System: input, time, menu
|
||||
|
||||
```swift
|
||||
// Buttons are an OptionSet: current (held), pushed and released this frame.
|
||||
let (current, pushed, released) = Playdate.System.buttonState
|
||||
if current.contains([.b, .down]) { /* charge shot */ }
|
||||
|
||||
// Crank.
|
||||
if !Playdate.System.isCrankDocked {
|
||||
aim(degrees: Playdate.System.crankAngle)
|
||||
spin(by: Playdate.System.crankChange)
|
||||
}
|
||||
|
||||
// Accelerometer is a peripheral you enable first.
|
||||
Playdate.System.setPeripheralsEnabled(.accelerometer)
|
||||
let (x, y, z) = Playdate.System.accelerometer
|
||||
|
||||
// System menu items take closures; the binding keeps them alive until removed.
|
||||
Playdate.System.addCheckmarkMenuItem(title: "music", isChecked: true) { item in
|
||||
Audio.musicEnabled = item.isChecked
|
||||
}
|
||||
Playdate.System.addOptionsMenuItem(title: "mode", options: ["easy", "hard"]) { item in
|
||||
Game.shared.difficulty = item.value
|
||||
}
|
||||
|
||||
// Logging goes to the simulator console or device serial.
|
||||
Playdate.System.log("spawned \(count) enemies")
|
||||
Playdate.System.error("unrecoverable") // stops execution
|
||||
```
|
||||
|
||||
### Graphics: drawing, bitmaps, fonts
|
||||
|
||||
Fallible loads (`Bitmap(path:)`, `Font(path:)`, …) throw `Playdate.Error`,
|
||||
which carries the message produced by the OS:
|
||||
|
||||
```swift
|
||||
let font = try Playdate.Graphics.Font(path: "fonts/Asheville-Sans-14-Bold.pft")
|
||||
Playdate.Graphics.setFont(font)
|
||||
|
||||
Playdate.Graphics.clear(color: .white)
|
||||
Playdate.Graphics.fillRect(x: 0, y: 0, width: 400, height: 32, color: .black)
|
||||
Playdate.Graphics.drawText("Hëllo, Playdate", x: 8, y: 8)
|
||||
|
||||
// Colors are solid or 8×8 patterns.
|
||||
let checker = Playdate.Graphics.Pattern(rows: (0xAA, 0x55, 0xAA, 0x55,
|
||||
0xAA, 0x55, 0xAA, 0x55))
|
||||
Playdate.Graphics.fillEllipse(x: 100, y: 100, width: 64, height: 64,
|
||||
color: .pattern(checker))
|
||||
|
||||
// Bitmaps draw themselves; draw into one by pushing it as the context.
|
||||
let logo = try Playdate.Graphics.Bitmap(path: "images/logo")
|
||||
logo.draw(x: 168, y: 88)
|
||||
|
||||
let canvas = Playdate.Graphics.Bitmap(width: 64, height: 64)
|
||||
Playdate.Graphics.pushContext(canvas)
|
||||
Playdate.Graphics.drawLine(x1: 0, y1: 0, x2: 63, y2: 63, width: 2, color: .black)
|
||||
Playdate.Graphics.popContext()
|
||||
```
|
||||
|
||||
### Sprites and collisions
|
||||
|
||||
```swift
|
||||
let ball = Playdate.Sprite()
|
||||
ball.setImage(try Playdate.Graphics.Bitmap(path: "images/ball"))
|
||||
ball.moveTo(x: 200, y: 120)
|
||||
ball.collideRect = Playdate.Rect(x: 0, y: 0, width: 16, height: 16)
|
||||
ball.setCollisionResponseFunction { _, _ in .bounce }
|
||||
ball.add() // adds to the display list; the binding keeps it alive while added
|
||||
|
||||
// In the update callback:
|
||||
let (actual, collisions) = ball.moveWithCollisions(goalX: goalX, goalY: goalY)
|
||||
for collision in collisions where collision.other.tag == Tags.brick {
|
||||
collision.other.remove()
|
||||
}
|
||||
```
|
||||
|
||||
Sprite callbacks (`setUpdateFunction`, `setDrawFunction`,
|
||||
`setCollisionResponseFunction`) receive the Swift wrapper back. The C-level
|
||||
sprite userdata slot is reserved by the binding for that recovery — use the
|
||||
`userdata` property on `Sprite` for your own per-sprite storage instead.
|
||||
|
||||
### Sound
|
||||
|
||||
```swift
|
||||
// Stream music from disk.
|
||||
let music = try Playdate.Sound.FilePlayer(path: "audio/theme")
|
||||
music.play(repeat: 0) // 0 = loop forever
|
||||
|
||||
// Play short effects from memory.
|
||||
let blip = try Playdate.Sound.SamplePlayer(path: "audio/blip")
|
||||
blip.play()
|
||||
|
||||
// Synthesis.
|
||||
let synth = Playdate.Sound.Synth(waveform: .square)
|
||||
synth.setAttackTime(0.01)
|
||||
synth.setReleaseTime(0.2)
|
||||
synth.playMIDINote(Playdate.Sound.noteC4, velocity: 0.8, length: 0.5)
|
||||
|
||||
// Channels mix sources and effects.
|
||||
let channel = Playdate.Sound.Channel()
|
||||
channel.add()
|
||||
channel.addSource(synth)
|
||||
let filter = Playdate.Sound.TwoPoleFilter(kind: .lowPass)
|
||||
filter.setFrequency(800)
|
||||
channel.addEffect(filter)
|
||||
|
||||
// Anything that takes a modulator accepts any SignalValue (LFO, Envelope, …).
|
||||
let wobble = Playdate.Sound.LFO(shape: .sine)
|
||||
wobble.setRate(2)
|
||||
synth.frequencyModulator = wobble
|
||||
```
|
||||
|
||||
### Files and JSON
|
||||
|
||||
```swift
|
||||
// Paths resolve against the game's Data directory and pdx per the open mode.
|
||||
let save = try Playdate.File.Handle(path: "save.json", mode: .write)
|
||||
try save.write(Playdate.JSON.encode(.table([
|
||||
"level": .int(3),
|
||||
"name": .string("Röck"),
|
||||
])))
|
||||
try save.close()
|
||||
|
||||
let loaded = try Playdate.JSON.decodeFile(at: "save.json")
|
||||
if case .table(let entries) = loaded, case .int(let level)? = entries["level"] {
|
||||
Game.shared.level = level
|
||||
}
|
||||
|
||||
try Playdate.File.listFiles(at: "replays") { name in
|
||||
Playdate.System.log("found \(name)")
|
||||
}
|
||||
```
|
||||
|
||||
### Network
|
||||
|
||||
Network access requires user permission per server:
|
||||
|
||||
```swift
|
||||
let reply = Playdate.Network.HTTPConnection.requestAccess(
|
||||
server: "example.com", purpose: "Fetching daily puzzles") { allowed in
|
||||
guard allowed else { return }
|
||||
Puzzles.fetch()
|
||||
}
|
||||
|
||||
func fetch() {
|
||||
guard let connection = Playdate.Network.HTTPConnection(server: "example.com") else { return }
|
||||
connection.setRequestCompleteCallback { connection in
|
||||
let body = try? connection.read(length: connection.bytesAvailable)
|
||||
// … keep `connection` referenced somewhere until this fires …
|
||||
}
|
||||
try? connection.get(path: "/daily.json")
|
||||
}
|
||||
```
|
||||
|
||||
### Lua interop
|
||||
|
||||
Lua callbacks are C function pointers with no context, so they must be
|
||||
`@convention(c)` functions rather than capturing closures:
|
||||
|
||||
```swift
|
||||
let double: Playdate.Lua.CFunction = { _ in
|
||||
Playdate.Lua.push(Playdate.Lua.intArgument(at: 1) * 2)
|
||||
return 1 // number of return values pushed
|
||||
}
|
||||
try Playdate.Lua.addFunction(double, name: "mylib.double")
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Namespaces.** Everything lives under `Playdate`. Games that find that
|
||||
verbose can alias: `typealias Graphics = Playdate.Graphics`.
|
||||
- **Errors.** Fallible operations use typed throws — `throws(Playdate.Error)`
|
||||
generally, `throws(Playdate.Network.NetError)` for network I/O — so `catch`
|
||||
gives you a concrete type, and no `any Error` existentials are needed.
|
||||
- **Ownership.** A wrapper that *creates* a C object frees it on `deinit`;
|
||||
keep the wrapper referenced for as long as you use it. Wrappers vending
|
||||
OS-owned objects (a `Bitmap` from a `BitmapTable`, a track from a
|
||||
`Sequence`, …) don't free them — keep the owner alive instead, as
|
||||
documented on each API. Resources a C object keeps referencing (a sprite's
|
||||
image, a synth's sample, modulators, menu-item option titles) are retained
|
||||
by the wrapper automatically.
|
||||
- **Callbacks.** Where the C API provides a userdata slot, closures are
|
||||
supported everywhere and delivered back with the right wrapper. A few C
|
||||
callbacks have no userdata (serial messages, headphone changes, scoreboard
|
||||
completions, `getServerTime`); those track one Swift closure at a time, as
|
||||
noted in their documentation.
|
||||
- **Threading.** The Playdate runtime is single-threaded (audio callbacks
|
||||
excepted); statics in the binding are `nonisolated(unsafe)` on that basis.
|
||||
Don't call the API from other threads.
|
||||
|
||||
## Building for the simulator and device
|
||||
|
||||
This package builds as a plain Swift library, which is how you develop and
|
||||
unit-test game logic on the host (`swift test` works out of the box).
|
||||
|
||||
Shipping a `.pdx` needs the Playdate toolchain on top:
|
||||
|
||||
- **Simulator** builds compile your game as a host dylib placed in the pdx.
|
||||
- **Device** builds require Embedded Swift for ARM Cortex-M7
|
||||
(`-enable-experimental-feature Embedded`, triple `armv7em-none-none-eabi`).
|
||||
|
||||
The wrappers are written within the Embedded Swift subset for exactly this
|
||||
reason: no Foundation, no reflection, no untyped throws. See Apple's
|
||||
[swift-playdate-examples](https://github.com/apple/swift-playdate-examples)
|
||||
for a working Makefile/toolchain setup that this library slots into.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Scripts/
|
||||
install-pkgconfig.sh One-time setup: points the "playdate" pkg-config
|
||||
module at your SDK installation
|
||||
Sources/
|
||||
CPlaydate/ System library target: module map + umbrella header
|
||||
importing pd_api.h from the SDK, plus inline shims for
|
||||
the variadic log/error functions
|
||||
PlayDate/ The Swift bindings, one file per subsystem
|
||||
(Sound and Graphics are split across several files)
|
||||
Tests/
|
||||
PlayDate/ Host-runnable tests for the pure value types
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE). The Playdate SDK itself is licensed separately
|
||||
by Panic, Inc. and is not distributed with this package.
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Generates a pkg-config file for the Playdate SDK C API so SwiftPM can
|
||||
# resolve the CPlaydate system library target.
|
||||
#
|
||||
# Usage:
|
||||
# Scripts/install-pkgconfig.sh [destination-directory]
|
||||
#
|
||||
# The SDK is located via $PLAYDATE_SDK_PATH, falling back to
|
||||
# ~/Developer/PlaydateSDK. Without a destination argument, the file is
|
||||
# installed into the first writable directory that SwiftPM (including
|
||||
# Xcode's SwiftPM) searches by default.
|
||||
|
||||
set -eu
|
||||
|
||||
sdk_path="${PLAYDATE_SDK_PATH:-$HOME/Developer/PlaydateSDK}"
|
||||
|
||||
if [ ! -f "$sdk_path/C_API/pd_api.h" ]; then
|
||||
echo "error: pd_api.h not found under '$sdk_path/C_API'." >&2
|
||||
echo "Install the Playdate SDK or set PLAYDATE_SDK_PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="unknown"
|
||||
if [ -f "$sdk_path/VERSION.txt" ]; then
|
||||
version="$(head -1 "$sdk_path/VERSION.txt" | tr -d '[:space:]')"
|
||||
fi
|
||||
|
||||
destination="${1:-}"
|
||||
if [ -z "$destination" ]; then
|
||||
# Locations SwiftPM (and Xcode's SwiftPM) searches without
|
||||
# PKG_CONFIG_PATH. Note: Homebrew's /opt/homebrew/lib/pkgconfig is NOT
|
||||
# searched by default.
|
||||
for candidate in /usr/local/lib/pkgconfig /usr/local/share/pkgconfig; do
|
||||
parent="$(dirname "$candidate")"
|
||||
if [ -d "$candidate" ] && [ -w "$candidate" ]; then
|
||||
destination="$candidate"
|
||||
break
|
||||
elif [ -d "$parent" ] && [ -w "$parent" ]; then
|
||||
destination="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$destination" ]; then
|
||||
echo "error: no writable pkg-config directory found." >&2
|
||||
echo "Re-run with a destination directory, e.g.:" >&2
|
||||
echo " sudo Scripts/install-pkgconfig.sh /usr/local/lib/pkgconfig" >&2
|
||||
echo "or pick your own directory and add it to PKG_CONFIG_PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$destination"
|
||||
cat > "$destination/playdate.pc" <<EOF
|
||||
prefix=$sdk_path
|
||||
|
||||
Name: playdate
|
||||
Description: Playdate SDK C API headers
|
||||
Version: $version
|
||||
Cflags: -I\${prefix}/C_API
|
||||
EOF
|
||||
|
||||
echo "Wrote $destination/playdate.pc (SDK $version at $sdk_path)"
|
||||
|
||||
case "$destination" in
|
||||
/usr/local/lib/pkgconfig|/usr/local/share/pkgconfig|/usr/lib/pkgconfig|/usr/share/pkgconfig)
|
||||
;;
|
||||
*)
|
||||
echo "note: '$destination' is not on SwiftPM's default search path;"
|
||||
echo " export PKG_CONFIG_PATH=\"$destination\" when building."
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// CPlaydate.h
|
||||
// Umbrella header exposing the Playdate C API to Swift.
|
||||
//
|
||||
// pd_api.h is resolved through the "playdate" pkg-config module, which
|
||||
// points at $PLAYDATE_SDK_PATH/C_API. Run Scripts/install-pkgconfig.sh
|
||||
// once to set that up.
|
||||
//
|
||||
|
||||
#ifndef CPLAYDATE_H
|
||||
#define CPLAYDATE_H
|
||||
|
||||
// The C API only declares the event/system types for extension builds.
|
||||
#ifndef TARGET_EXTENSION
|
||||
#define TARGET_EXTENSION 1
|
||||
#endif
|
||||
|
||||
#include "pd_api.h"
|
||||
|
||||
// Swift cannot call variadic C function pointers such as
|
||||
// playdate->system->logToConsole. These shims route a plain string through
|
||||
// the "%s" format, which also avoids format-string injection.
|
||||
|
||||
static inline void cplaydate_log(PlaydateAPI *playdate, const char *message)
|
||||
{
|
||||
playdate->system->logToConsole("%s", message);
|
||||
}
|
||||
|
||||
static inline void cplaydate_error(PlaydateAPI *playdate, const char *message)
|
||||
{
|
||||
playdate->system->error("%s", message);
|
||||
}
|
||||
|
||||
#endif /* CPLAYDATE_H */
|
||||
@@ -0,0 +1,4 @@
|
||||
module CPlaydate {
|
||||
header "CPlaydate.h"
|
||||
export *
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// Display.swift
|
||||
// Wraps `playdate->display` (pd_api_display.h).
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
extension Playdate {
|
||||
/// The display API: resolution, refresh rate, scaling, and effects.
|
||||
public enum Display {}
|
||||
}
|
||||
|
||||
extension Playdate.Display {
|
||||
private static var api: playdate_display { Playdate.api.display.pointee }
|
||||
|
||||
/// The display width in pixels, taking the current scale into account.
|
||||
public static var width: Int { Int(api.getWidth.unsafelyUnwrapped()) }
|
||||
|
||||
/// The display height in pixels, taking the current scale into account.
|
||||
public static var height: Int { Int(api.getHeight.unsafelyUnwrapped()) }
|
||||
|
||||
/// Sets the nominal refresh rate in frames per second. Pass 0 to update
|
||||
/// as fast as possible (the update callback drives the pace).
|
||||
public static func setRefreshRate(_ rate: Float) {
|
||||
api.setRefreshRate.unsafelyUnwrapped(rate)
|
||||
}
|
||||
|
||||
/// The current nominal refresh rate.
|
||||
public static var refreshRate: Float { api.getRefreshRate.unsafelyUnwrapped() }
|
||||
|
||||
/// The measured average frames per second.
|
||||
public static var fps: Float { api.getFPS.unsafelyUnwrapped() }
|
||||
|
||||
/// Draws the frame white-on-black when `true`.
|
||||
public static func setInverted(_ inverted: Bool) {
|
||||
api.setInverted.unsafelyUnwrapped(inverted ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Sets the display scale factor: 1, 2, 4, or 8.
|
||||
public static func setScale(_ scale: UInt32) {
|
||||
api.setScale.unsafelyUnwrapped(scale)
|
||||
}
|
||||
|
||||
/// Adds a mosaic effect. Valid values for each axis are 0...3.
|
||||
public static func setMosaic(x: UInt32, y: UInt32) {
|
||||
api.setMosaic.unsafelyUnwrapped(x, y)
|
||||
}
|
||||
|
||||
/// Flips the display on the given axes.
|
||||
public static func setFlipped(x: Bool, y: Bool) {
|
||||
api.setFlipped.unsafelyUnwrapped(x ? 1 : 0, y ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Offsets the display by the given amount. Areas outside the frame
|
||||
/// buffer draw black.
|
||||
public static func setOffset(x: Int, y: Int) {
|
||||
api.setOffset.unsafelyUnwrapped(Int32(x), Int32(y))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//
|
||||
// File.swift
|
||||
// Wraps `playdate->file` (pd_api_file.h).
|
||||
//
|
||||
// Paths are relative to the game's Data directory (read/write) or the
|
||||
// game's pdx (read-only), per the mode used to open them.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
private var fileAPI: playdate_file { Playdate.api.file.pointee }
|
||||
|
||||
/// The most recent file system error as a thrown error.
|
||||
private func lastFileError() -> Playdate.Error {
|
||||
Playdate.Error(cString: fileAPI.geterr.unsafelyUnwrapped())
|
||||
}
|
||||
|
||||
extension Playdate {
|
||||
/// The file API: access to the game's Data directory and pdx contents.
|
||||
public enum File {}
|
||||
}
|
||||
|
||||
extension Playdate.File {
|
||||
// MARK: - Types
|
||||
|
||||
/// How to open a file.
|
||||
public struct Options: OptionSet, Sendable {
|
||||
public let rawValue: UInt32
|
||||
public init(rawValue: UInt32) { self.rawValue = rawValue }
|
||||
|
||||
/// Read from the game pdx, then the Data directory.
|
||||
public static let read = Options(rawValue: kFileRead.rawValue)
|
||||
/// Read from the Data directory only.
|
||||
public static let readData = Options(rawValue: kFileReadData.rawValue)
|
||||
/// Write to the Data directory, truncating an existing file.
|
||||
public static let write = Options(rawValue: kFileWrite.rawValue)
|
||||
/// Write to the Data directory, appending to an existing file.
|
||||
public static let append = Options(rawValue: kFileAppend.rawValue)
|
||||
|
||||
var cValue: FileOptions { FileOptions(rawValue) }
|
||||
}
|
||||
|
||||
/// Information about a file or directory, mirroring `FileStat`.
|
||||
public struct Stat: Sendable {
|
||||
public let isDirectory: Bool
|
||||
public let size: UInt32
|
||||
public let modified: Playdate.System.DateTime
|
||||
}
|
||||
|
||||
/// The origin used by `Handle.seek(to:from:)`.
|
||||
public enum SeekOrigin: Int32, Sendable {
|
||||
case start = 0
|
||||
case current = 1
|
||||
case end = 2
|
||||
}
|
||||
|
||||
// MARK: - Directory operations
|
||||
|
||||
/// Calls `each` with the name of every file in `path`. Subdirectory names
|
||||
/// end in a slash. Throws if the directory does not exist.
|
||||
public static func listFiles(at path: String, showHidden: Bool = false,
|
||||
_ each: (String) -> Void) throws(Playdate.Error) {
|
||||
let result = withoutActuallyEscaping(each) { each in
|
||||
var callback = each
|
||||
return path.withPlaydateCString { cPath in
|
||||
withUnsafeMutablePointer(to: &callback) { callbackPointer in
|
||||
fileAPI.listfiles.unsafelyUnwrapped(cPath, { cName, userdata in
|
||||
guard let cName, let userdata else { return }
|
||||
let each = userdata.assumingMemoryBound(to: ((String) -> Void).self).pointee
|
||||
each(String(playdateCString: cName))
|
||||
}, callbackPointer, showHidden ? 1 : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if result != 0 { throw lastFileError() }
|
||||
}
|
||||
|
||||
/// Information about the file or directory at `path`.
|
||||
public static func stat(_ path: String) throws(Playdate.Error) -> Stat {
|
||||
var stat = FileStat()
|
||||
let result = path.withPlaydateCString { fileAPI.stat.unsafelyUnwrapped($0, &stat) }
|
||||
if result != 0 { throw lastFileError() }
|
||||
return Stat(
|
||||
isDirectory: stat.isdir != 0,
|
||||
size: stat.size,
|
||||
modified: Playdate.System.DateTime(
|
||||
year: UInt16(stat.m_year), month: UInt8(stat.m_month), day: UInt8(stat.m_day),
|
||||
hour: UInt8(stat.m_hour), minute: UInt8(stat.m_minute), second: UInt8(stat.m_second)))
|
||||
}
|
||||
|
||||
/// Creates a directory (and intermediate directories) in the Data directory.
|
||||
public static func mkdir(_ path: String) throws(Playdate.Error) {
|
||||
let result = path.withPlaydateCString { fileAPI.mkdir.unsafelyUnwrapped($0) }
|
||||
if result != 0 { throw lastFileError() }
|
||||
}
|
||||
|
||||
/// Deletes the file or directory at `path`. Directories require
|
||||
/// `recursive` to be deleted with their contents.
|
||||
public static func unlink(_ path: String, recursive: Bool = false) throws(Playdate.Error) {
|
||||
let result = path.withPlaydateCString {
|
||||
fileAPI.unlink.unsafelyUnwrapped($0, recursive ? 1 : 0)
|
||||
}
|
||||
if result != 0 { throw lastFileError() }
|
||||
}
|
||||
|
||||
/// Renames (moves) a file in the Data directory, overwriting any existing
|
||||
/// file at the destination.
|
||||
public static func rename(from: String, to: String) throws(Playdate.Error) {
|
||||
let result = from.withPlaydateCString { cFrom in
|
||||
to.withPlaydateCString { cTo in
|
||||
fileAPI.rename.unsafelyUnwrapped(cFrom, cTo)
|
||||
}
|
||||
}
|
||||
if result != 0 { throw lastFileError() }
|
||||
}
|
||||
|
||||
// MARK: - Open files
|
||||
|
||||
/// An open file. Wraps `SDFile`. The file is closed on deinit if it has
|
||||
/// not been closed explicitly.
|
||||
public final class Handle {
|
||||
let pointer: UnsafeMutableRawPointer
|
||||
private var isClosed = false
|
||||
|
||||
/// Opens the file at `path`.
|
||||
public init(path: String, mode: Options) throws(Playdate.Error) {
|
||||
let pointer = path.withPlaydateCString {
|
||||
fileAPI.open.unsafelyUnwrapped($0, mode.cValue)
|
||||
}
|
||||
guard let pointer else { throw lastFileError() }
|
||||
self.pointer = pointer
|
||||
}
|
||||
|
||||
deinit {
|
||||
if !isClosed {
|
||||
_ = fileAPI.close.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the file. Further operations are invalid.
|
||||
public func close() throws(Playdate.Error) {
|
||||
guard !isClosed else { return }
|
||||
isClosed = true
|
||||
if fileAPI.close.unsafelyUnwrapped(pointer) != 0 { throw lastFileError() }
|
||||
}
|
||||
|
||||
/// Reads up to `buffer.count` bytes into `buffer`. Returns the number
|
||||
/// of bytes read; 0 indicates end of file.
|
||||
public func read(into buffer: UnsafeMutableRawBufferPointer) throws(Playdate.Error) -> Int {
|
||||
let result = fileAPI.read.unsafelyUnwrapped(
|
||||
pointer, buffer.baseAddress, UInt32(buffer.count))
|
||||
if result < 0 { throw lastFileError() }
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
/// Reads up to `length` bytes and returns them.
|
||||
public func read(length: Int) throws(Playdate.Error) -> [UInt8] {
|
||||
var bytes = [UInt8](repeating: 0, count: length)
|
||||
let result = bytes.withUnsafeMutableBytes { buffer in
|
||||
fileAPI.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count))
|
||||
}
|
||||
if result < 0 { throw lastFileError() }
|
||||
bytes.removeLast(length - Int(result))
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// Writes the buffer to the file. Returns the number of bytes written.
|
||||
@discardableResult
|
||||
public func write(_ buffer: UnsafeRawBufferPointer) throws(Playdate.Error) -> Int {
|
||||
let result = fileAPI.write.unsafelyUnwrapped(
|
||||
pointer, buffer.baseAddress, UInt32(buffer.count))
|
||||
if result < 0 { throw lastFileError() }
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
/// Writes the bytes to the file. Returns the number of bytes written.
|
||||
@discardableResult
|
||||
public func write(_ bytes: [UInt8]) throws(Playdate.Error) -> Int {
|
||||
let result = bytes.withUnsafeBytes { buffer in
|
||||
fileAPI.write.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count))
|
||||
}
|
||||
if result < 0 { throw lastFileError() }
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
/// Writes the string's UTF-8 to the file. Returns the bytes written.
|
||||
@discardableResult
|
||||
public func write(_ string: String) throws(Playdate.Error) -> Int {
|
||||
try write(Array(string.utf8))
|
||||
}
|
||||
|
||||
/// Flushes buffered writes to disk. Returns the bytes written.
|
||||
@discardableResult
|
||||
public func flush() throws(Playdate.Error) -> Int {
|
||||
let result = fileAPI.flush.unsafelyUnwrapped(pointer)
|
||||
if result < 0 { throw lastFileError() }
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
/// The current read/write offset.
|
||||
public func tell() throws(Playdate.Error) -> Int {
|
||||
let result = fileAPI.tell.unsafelyUnwrapped(pointer)
|
||||
if result < 0 { throw lastFileError() }
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
/// Moves the read/write offset to `offset` relative to `origin`.
|
||||
public func seek(to offset: Int, from origin: SeekOrigin = .start) throws(Playdate.Error) {
|
||||
if fileAPI.seek.unsafelyUnwrapped(pointer, Int32(offset), origin.rawValue) != 0 {
|
||||
throw lastFileError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//
|
||||
// Graphics.swift
|
||||
// Wraps `playdate->graphics` (pd_api_gfx.h): drawing state, shapes, text,
|
||||
// and raw framebuffer access. Bitmap, font, tilemap, and video wrappers live
|
||||
// in their own files.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
extension Playdate {
|
||||
/// The graphics API: drawing, bitmaps, fonts, tilemaps, and video.
|
||||
public enum Graphics {}
|
||||
}
|
||||
|
||||
var gfx: playdate_graphics { Playdate.api.graphics.pointee }
|
||||
|
||||
extension Playdate.Graphics {
|
||||
// MARK: - Screen constants
|
||||
|
||||
/// The width of the screen in pixels (`LCD_COLUMNS`).
|
||||
public static let columns = 400
|
||||
/// The height of the screen in pixels (`LCD_ROWS`).
|
||||
public static let rows = 240
|
||||
/// The stride of a framebuffer row in bytes (`LCD_ROWSIZE`).
|
||||
public static let rowSize = 52
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
/// An 8×8 two-color pattern: 8 rows of image data followed by 8 rows of mask.
|
||||
public struct Pattern: Sendable {
|
||||
public var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
|
||||
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)
|
||||
|
||||
public init(bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
|
||||
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) {
|
||||
self.bytes = bytes
|
||||
}
|
||||
|
||||
/// Creates an opaque pattern from 8 rows of image data.
|
||||
public init(rows r: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) {
|
||||
bytes = (r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff)
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing color: solid or an 8×8 pattern.
|
||||
public enum Color: Sendable {
|
||||
case black
|
||||
case white
|
||||
case clear
|
||||
case xor
|
||||
case pattern(Pattern)
|
||||
|
||||
/// Materializes the `LCDColor` for the duration of `body`. Pattern
|
||||
/// colors pass a pointer to a temporary, so the value must not be
|
||||
/// stored beyond the call.
|
||||
func withLCDColor<Result>(_ body: (LCDColor) -> Result) -> Result {
|
||||
switch self {
|
||||
case .black: return body(LCDColor(kColorBlack.rawValue))
|
||||
case .white: return body(LCDColor(kColorWhite.rawValue))
|
||||
case .clear: return body(LCDColor(kColorClear.rawValue))
|
||||
case .xor: return body(LCDColor(kColorXOR.rawValue))
|
||||
case .pattern(let pattern):
|
||||
return withUnsafeBytes(of: pattern.bytes) { buffer in
|
||||
body(LCDColor(UInt(bitPattern: buffer.baseAddress)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A solid color, for APIs that cannot take a pattern.
|
||||
public enum SolidColor: UInt32, Sendable {
|
||||
case black = 0
|
||||
case white = 1
|
||||
case clear = 2
|
||||
case xor = 3
|
||||
|
||||
init(_ color: LCDSolidColor) { self = SolidColor(rawValue: color.rawValue) ?? .clear }
|
||||
var cValue: LCDSolidColor { LCDSolidColor(rawValue) }
|
||||
}
|
||||
|
||||
/// How source pixels combine with the destination when drawing.
|
||||
public enum DrawMode: UInt32, Sendable {
|
||||
case copy = 0
|
||||
case whiteTransparent = 1
|
||||
case blackTransparent = 2
|
||||
case fillWhite = 3
|
||||
case fillBlack = 4
|
||||
case xor = 5
|
||||
case nxor = 6
|
||||
case inverted = 7
|
||||
|
||||
init(_ mode: LCDBitmapDrawMode) { self = DrawMode(rawValue: mode.rawValue) ?? .copy }
|
||||
var cValue: LCDBitmapDrawMode { LCDBitmapDrawMode(rawValue) }
|
||||
}
|
||||
|
||||
/// Mirroring applied when drawing a bitmap.
|
||||
public enum BitmapFlip: UInt32, Sendable {
|
||||
case unflipped = 0
|
||||
case flippedX = 1
|
||||
case flippedY = 2
|
||||
case flippedXY = 3
|
||||
|
||||
init(_ flip: LCDBitmapFlip) { self = BitmapFlip(rawValue: flip.rawValue) ?? .unflipped }
|
||||
var cValue: LCDBitmapFlip { LCDBitmapFlip(rawValue) }
|
||||
}
|
||||
|
||||
/// The end cap style used when drawing lines.
|
||||
public enum LineCapStyle: UInt32, Sendable {
|
||||
case butt = 0
|
||||
case square = 1
|
||||
case round = 2
|
||||
|
||||
var cValue: LCDLineCapStyle { LCDLineCapStyle(rawValue) }
|
||||
}
|
||||
|
||||
/// The encoding of text passed to the text functions.
|
||||
public enum StringEncoding: UInt32, Sendable {
|
||||
case ascii = 0
|
||||
case utf8 = 1
|
||||
case utf16LittleEndian = 2
|
||||
|
||||
var cValue: PDStringEncoding { PDStringEncoding(rawValue) }
|
||||
}
|
||||
|
||||
/// The winding rule used by `fillPolygon`.
|
||||
public enum PolygonFillRule: UInt32, Sendable {
|
||||
case nonZero = 0
|
||||
case evenOdd = 1
|
||||
|
||||
var cValue: LCDPolygonFillRule { LCDPolygonFillRule(rawValue) }
|
||||
}
|
||||
|
||||
/// How text wraps in `drawText(in:)`.
|
||||
public enum TextWrappingMode: UInt32, Sendable {
|
||||
case clip = 0
|
||||
case character = 1
|
||||
case word = 2
|
||||
|
||||
var cValue: PDTextWrappingMode { PDTextWrappingMode(rawValue) }
|
||||
}
|
||||
|
||||
/// Horizontal alignment for `drawText(in:)`.
|
||||
public enum TextAlignment: UInt32, Sendable {
|
||||
case left = 0
|
||||
case center = 1
|
||||
case right = 2
|
||||
|
||||
var cValue: PDTextAlignment { PDTextAlignment(rawValue) }
|
||||
}
|
||||
|
||||
/// An integer rectangle mirroring `LCDRect`. `right` and `bottom` are
|
||||
/// not inclusive.
|
||||
public struct Rect: Sendable {
|
||||
public var left: Int
|
||||
public var right: Int
|
||||
public var top: Int
|
||||
public var bottom: Int
|
||||
|
||||
public init(left: Int, right: Int, top: Int, bottom: Int) {
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.top = top
|
||||
self.bottom = bottom
|
||||
}
|
||||
|
||||
public init(x: Int, y: Int, width: Int, height: Int) {
|
||||
self.init(left: x, right: x + width, top: y, bottom: y + height)
|
||||
}
|
||||
|
||||
init(_ rect: LCDRect) {
|
||||
self.init(left: Int(rect.left), right: Int(rect.right),
|
||||
top: Int(rect.top), bottom: Int(rect.bottom))
|
||||
}
|
||||
|
||||
var cValue: LCDRect {
|
||||
LCDRect(left: Int32(left), right: Int32(right),
|
||||
top: Int32(top), bottom: Int32(bottom))
|
||||
}
|
||||
|
||||
public func translated(dx: Int, dy: Int) -> Rect {
|
||||
Rect(left: left + dx, right: right + dx, top: top + dy, bottom: bottom + dy)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drawing state
|
||||
|
||||
/// Clears the entire display, filling it with `color`.
|
||||
public static func clear(color: Color = .white) {
|
||||
color.withLCDColor { gfx.clear.unsafelyUnwrapped($0) }
|
||||
}
|
||||
|
||||
/// Sets the background color shown when the display is offset or for
|
||||
/// clear pixels in drawn images.
|
||||
public static func setBackgroundColor(_ color: SolidColor) {
|
||||
gfx.setBackgroundColor.unsafelyUnwrapped(color.cValue)
|
||||
}
|
||||
|
||||
/// Sets the mode that determines how source pixels combine with the
|
||||
/// destination. Returns the previous mode.
|
||||
@discardableResult
|
||||
public static func setDrawMode(_ mode: DrawMode) -> DrawMode {
|
||||
DrawMode(gfx.setDrawMode.unsafelyUnwrapped(mode.cValue))
|
||||
}
|
||||
|
||||
/// Offsets all subsequent drawing by (dx, dy).
|
||||
public static func setDrawOffset(dx: Int, dy: Int) {
|
||||
gfx.setDrawOffset.unsafelyUnwrapped(Int32(dx), Int32(dy))
|
||||
}
|
||||
|
||||
/// Sets the clip rect in world coordinates (affected by the draw offset).
|
||||
public static func setClipRect(x: Int, y: Int, width: Int, height: Int) {
|
||||
gfx.setClipRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height))
|
||||
}
|
||||
|
||||
/// Sets the clip rect in screen coordinates (unaffected by the draw offset).
|
||||
public static func setScreenClipRect(x: Int, y: Int, width: Int, height: Int) {
|
||||
gfx.setScreenClipRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height))
|
||||
}
|
||||
|
||||
public static func clearClipRect() {
|
||||
gfx.clearClipRect.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
public static func setLineCapStyle(_ style: LineCapStyle) {
|
||||
gfx.setLineCapStyle.unsafelyUnwrapped(style.cValue)
|
||||
}
|
||||
|
||||
/// Sets the stencil applied to subsequent drawing. If `tile` is `true`
|
||||
/// the stencil image is tiled, and its width must be a multiple of 32.
|
||||
/// Pass `nil` to clear the stencil.
|
||||
public static func setStencil(_ image: Bitmap?, tile: Bool = false) {
|
||||
gfx.setStencilImage.unsafelyUnwrapped(image?.pointer, tile ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Pushes a new drawing context targeting `target`, or the display if
|
||||
/// `target` is `nil`.
|
||||
public static func pushContext(_ target: Bitmap? = nil) {
|
||||
gfx.pushContext.unsafelyUnwrapped(target?.pointer)
|
||||
}
|
||||
|
||||
public static func popContext() {
|
||||
gfx.popContext.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
// MARK: - Shapes
|
||||
|
||||
public static func drawLine(x1: Int, y1: Int, x2: Int, y2: Int, width: Int, color: Color) {
|
||||
color.withLCDColor {
|
||||
gfx.drawLine.unsafelyUnwrapped(Int32(x1), Int32(y1), Int32(x2), Int32(y2), Int32(width), $0)
|
||||
}
|
||||
}
|
||||
|
||||
public static func fillTriangle(x1: Int, y1: Int, x2: Int, y2: Int, x3: Int, y3: Int, color: Color) {
|
||||
color.withLCDColor {
|
||||
gfx.fillTriangle.unsafelyUnwrapped(Int32(x1), Int32(y1), Int32(x2), Int32(y2),
|
||||
Int32(x3), Int32(y3), $0)
|
||||
}
|
||||
}
|
||||
|
||||
public static func drawRect(x: Int, y: Int, width: Int, height: Int, color: Color) {
|
||||
color.withLCDColor {
|
||||
gfx.drawRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height), $0)
|
||||
}
|
||||
}
|
||||
|
||||
public static func fillRect(x: Int, y: Int, width: Int, height: Int, color: Color) {
|
||||
color.withLCDColor {
|
||||
gfx.fillRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height), $0)
|
||||
}
|
||||
}
|
||||
|
||||
public static func drawRoundRect(x: Int, y: Int, width: Int, height: Int, radius: Int,
|
||||
lineWidth: Int, color: Color) {
|
||||
color.withLCDColor {
|
||||
gfx.drawRoundRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height),
|
||||
Int32(radius), Int32(lineWidth), $0)
|
||||
}
|
||||
}
|
||||
|
||||
public static func fillRoundRect(x: Int, y: Int, width: Int, height: Int, radius: Int, color: Color) {
|
||||
color.withLCDColor {
|
||||
gfx.fillRoundRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height),
|
||||
Int32(radius), $0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws an ellipse stroked inside the rect. If the angles differ, draws
|
||||
/// an arc from `startAngle` to `endAngle` (clockwise degrees, 0 at top).
|
||||
public static func drawEllipse(x: Int, y: Int, width: Int, height: Int, lineWidth: Int,
|
||||
startAngle: Float = 0, endAngle: Float = 0, color: Color) {
|
||||
color.withLCDColor {
|
||||
gfx.drawEllipse.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height),
|
||||
Int32(lineWidth), startAngle, endAngle, $0)
|
||||
}
|
||||
}
|
||||
|
||||
public static func fillEllipse(x: Int, y: Int, width: Int, height: Int,
|
||||
startAngle: Float = 0, endAngle: Float = 0, color: Color) {
|
||||
color.withLCDColor {
|
||||
gfx.fillEllipse.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height),
|
||||
startAngle, endAngle, $0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills the polygon described by the points, connecting the last point
|
||||
/// back to the first.
|
||||
public static func fillPolygon(points: [(x: Int, y: Int)], color: Color,
|
||||
fillRule: PolygonFillRule = .nonZero) {
|
||||
var coordinates = [Int32]()
|
||||
coordinates.reserveCapacity(points.count * 2)
|
||||
for point in points {
|
||||
coordinates.append(Int32(point.x))
|
||||
coordinates.append(Int32(point.y))
|
||||
}
|
||||
color.withLCDColor { cColor in
|
||||
coordinates.withUnsafeMutableBufferPointer { buffer in
|
||||
gfx.fillPolygon.unsafelyUnwrapped(Int32(points.count), buffer.baseAddress,
|
||||
cColor, fillRule.cValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the pixel at (x, y) in the current drawing context.
|
||||
public static func setPixel(x: Int, y: Int, color: Color) {
|
||||
color.withLCDColor { gfx.setPixel.unsafelyUnwrapped(Int32(x), Int32(y), $0) }
|
||||
}
|
||||
|
||||
/// Reads an 8×8 pattern from the bitmap starting at (x, y).
|
||||
public static func colorToPattern(from bitmap: Bitmap, x: Int, y: Int) -> Pattern {
|
||||
var color: LCDColor = 0
|
||||
gfx.setColorToPattern.unsafelyUnwrapped(&color, bitmap.pointer, Int32(x), Int32(y))
|
||||
var pattern = Pattern(bytes: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
if let source = UnsafeRawPointer(bitPattern: UInt(color)) {
|
||||
withUnsafeMutableBytes(of: &pattern.bytes) { destination in
|
||||
destination.copyMemory(from: UnsafeRawBufferPointer(start: source, count: 16))
|
||||
}
|
||||
}
|
||||
return pattern
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
/// Draws `text` at (x, y) using the current font. Returns the drawn width.
|
||||
@discardableResult
|
||||
public static func drawText(_ text: String, x: Int, y: Int) -> Int {
|
||||
let utf8 = ContiguousArray(text.utf8)
|
||||
return utf8.withUnsafeBufferPointer { buffer in
|
||||
Int(gfx.drawText.unsafelyUnwrapped(buffer.baseAddress, buffer.count,
|
||||
kUTF8Encoding, Int32(x), Int32(y)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `text` wrapped and aligned inside the given rectangle.
|
||||
public static func drawText(_ text: String, x: Int, y: Int, width: Int, height: Int,
|
||||
wrap: TextWrappingMode = .word, align: TextAlignment = .left) {
|
||||
let utf8 = ContiguousArray(text.utf8)
|
||||
utf8.withUnsafeBufferPointer { buffer in
|
||||
gfx.drawTextInRect.unsafelyUnwrapped(buffer.baseAddress, buffer.count, kUTF8Encoding,
|
||||
Int32(x), Int32(y), Int32(width), Int32(height),
|
||||
wrap.cValue, align.cValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the font used by subsequent text drawing.
|
||||
public static func setFont(_ font: Font) {
|
||||
gfx.setFont.unsafelyUnwrapped(font.pointer)
|
||||
}
|
||||
|
||||
/// Extra space added between letters, in pixels.
|
||||
public static func setTextTracking(_ tracking: Int) {
|
||||
gfx.setTextTracking.unsafelyUnwrapped(Int32(tracking))
|
||||
}
|
||||
|
||||
public static var textTracking: Int {
|
||||
Int(gfx.getTextTracking.unsafelyUnwrapped())
|
||||
}
|
||||
|
||||
/// Adjusts the line height used when drawing multi-line text.
|
||||
public static func setTextLeading(_ lineHeightAdjustment: Int) {
|
||||
gfx.setTextLeading.unsafelyUnwrapped(Int32(lineHeightAdjustment))
|
||||
}
|
||||
|
||||
// MARK: - Framebuffer
|
||||
|
||||
/// The current working framebuffer. Rows are `rowSize` bytes.
|
||||
/// Call `markUpdatedRows(from:to:)` after writing directly.
|
||||
public static var frame: UnsafeMutablePointer<UInt8>? {
|
||||
gfx.getFrame.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
/// The framebuffer currently shown on the display. Rows are `rowSize` bytes.
|
||||
public static var displayFrame: UnsafeMutablePointer<UInt8>? {
|
||||
gfx.getDisplayFrame.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
/// A bitmap view of the display framebuffer. Simulator only; `nil` on device.
|
||||
public static var debugBitmap: Bitmap? {
|
||||
guard let getDebugBitmap = gfx.getDebugBitmap,
|
||||
let pointer = getDebugBitmap() else { return nil }
|
||||
return Bitmap(pointer: pointer, isOwned: false)
|
||||
}
|
||||
|
||||
/// A bitmap referencing the display framebuffer (not a copy).
|
||||
public static var displayBufferBitmap: Bitmap? {
|
||||
guard let pointer = gfx.getDisplayBufferBitmap.unsafelyUnwrapped() else { return nil }
|
||||
return Bitmap(pointer: pointer, isOwned: false)
|
||||
}
|
||||
|
||||
/// A copy of the working framebuffer as a new bitmap.
|
||||
public static func copyFrameBufferBitmap() -> Bitmap? {
|
||||
guard let pointer = gfx.copyFrameBufferBitmap.unsafelyUnwrapped() else { return nil }
|
||||
return Bitmap(pointer: pointer, isOwned: true)
|
||||
}
|
||||
|
||||
/// Tells the system which rows (inclusive) were changed by direct
|
||||
/// framebuffer writes and need redisplay.
|
||||
public static func markUpdatedRows(from start: Int, to end: Int) {
|
||||
gfx.markUpdatedRows.unsafelyUnwrapped(Int32(start), Int32(end))
|
||||
}
|
||||
|
||||
/// Manually flushes the framebuffer to the display. Only needed when
|
||||
/// drawing outside the normal update cycle.
|
||||
public static func display() {
|
||||
gfx.display.unsafelyUnwrapped()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//
|
||||
// GraphicsBitmap.swift
|
||||
// Bitmap and BitmapTable wrappers around LCDBitmap / LCDBitmapTable.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
extension Playdate.Graphics {
|
||||
/// An image that can be drawn to the screen or used as a drawing target.
|
||||
/// Wraps `LCDBitmap`.
|
||||
public final class Bitmap {
|
||||
let pointer: OpaquePointer
|
||||
/// Whether this wrapper owns the underlying `LCDBitmap` and frees it
|
||||
/// on deinit. Bitmaps vended by tables or the system are not owned;
|
||||
/// keep their owner alive while using them.
|
||||
let isOwned: Bool
|
||||
|
||||
init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
/// Allocates a new bitmap filled with `backgroundColor`.
|
||||
public convenience init(width: Int, height: Int, backgroundColor: Color = .clear) {
|
||||
let pointer = backgroundColor.withLCDColor {
|
||||
gfx.newBitmap.unsafelyUnwrapped(Int32(width), Int32(height), $0)
|
||||
}
|
||||
self.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
/// Loads a bitmap from a file in the game's pdx or Data directory.
|
||||
public convenience init(path: String) throws(Playdate.Error) {
|
||||
var error: UnsafePointer<CChar>?
|
||||
let pointer = path.withPlaydateCString { gfx.loadBitmap.unsafelyUnwrapped($0, &error) }
|
||||
guard let pointer else { throw Playdate.Error(cString: error) }
|
||||
self.init(pointer: pointer, isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
gfx.freeBitmap.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The bitmap's dimensions, row stride, and raw pixel/mask storage.
|
||||
/// The pointers are owned by the bitmap.
|
||||
public struct Data {
|
||||
public let width: Int
|
||||
public let height: Int
|
||||
public let rowBytes: Int
|
||||
public let mask: UnsafeMutablePointer<UInt8>?
|
||||
public let data: UnsafeMutablePointer<UInt8>?
|
||||
}
|
||||
|
||||
public var data: Data {
|
||||
var width: Int32 = 0, height: Int32 = 0, rowBytes: Int32 = 0
|
||||
var mask: UnsafeMutablePointer<UInt8>?
|
||||
var data: UnsafeMutablePointer<UInt8>?
|
||||
gfx.getBitmapData.unsafelyUnwrapped(pointer, &width, &height, &rowBytes, &mask, &data)
|
||||
return Data(width: Int(width), height: Int(height), rowBytes: Int(rowBytes),
|
||||
mask: mask, data: data)
|
||||
}
|
||||
|
||||
public var width: Int { data.width }
|
||||
public var height: Int { data.height }
|
||||
|
||||
/// The color of the pixel at (x, y).
|
||||
public func pixel(x: Int, y: Int) -> SolidColor {
|
||||
SolidColor(gfx.getBitmapPixel.unsafelyUnwrapped(pointer, Int32(x), Int32(y)))
|
||||
}
|
||||
|
||||
// MARK: Operations
|
||||
|
||||
/// Replaces the bitmap's contents with the image at `path`.
|
||||
public func load(path: String) throws(Playdate.Error) {
|
||||
var error: UnsafePointer<CChar>?
|
||||
path.withPlaydateCString { gfx.loadIntoBitmap.unsafelyUnwrapped($0, pointer, &error) }
|
||||
if let error { throw Playdate.Error(cString: error) }
|
||||
}
|
||||
|
||||
/// Fills the bitmap with `color`.
|
||||
public func clear(color: Color) {
|
||||
color.withLCDColor { gfx.clearBitmap.unsafelyUnwrapped(pointer, $0) }
|
||||
}
|
||||
|
||||
public func copy() -> Bitmap {
|
||||
Bitmap(pointer: gfx.copyBitmap.unsafelyUnwrapped(pointer).unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
/// Returns a new bitmap rotated by `degrees` (clockwise) and scaled.
|
||||
public func rotated(by degrees: Float, xScale: Float = 1, yScale: Float = 1) -> Bitmap? {
|
||||
var allocatedSize: Int32 = 0
|
||||
guard let rotated = gfx.rotatedBitmap.unsafelyUnwrapped(
|
||||
pointer, degrees, xScale, yScale, &allocatedSize) else { return nil }
|
||||
return Bitmap(pointer: rotated, isOwned: true)
|
||||
}
|
||||
|
||||
/// Sets a mask image. The mask must match the bitmap's dimensions.
|
||||
@discardableResult
|
||||
public func setMask(_ mask: Bitmap?) -> Bool {
|
||||
gfx.setBitmapMask.unsafelyUnwrapped(pointer, mask?.pointer) != 0
|
||||
}
|
||||
|
||||
/// The bitmap's mask, if any. The returned bitmap references storage
|
||||
/// owned by this bitmap.
|
||||
public var mask: Bitmap? {
|
||||
guard let mask = gfx.getBitmapMask.unsafelyUnwrapped(pointer) else { return nil }
|
||||
return Bitmap(pointer: mask, isOwned: false)
|
||||
}
|
||||
|
||||
/// Tests whether the opaque pixels of two bitmaps overlap within
|
||||
/// `rect`, given each bitmap's position and flip.
|
||||
public func checkMaskCollision(x: Int, y: Int, flip: BitmapFlip = .unflipped,
|
||||
other: Bitmap, otherX: Int, otherY: Int,
|
||||
otherFlip: BitmapFlip = .unflipped,
|
||||
in rect: Rect) -> Bool {
|
||||
gfx.checkMaskCollision.unsafelyUnwrapped(
|
||||
pointer, Int32(x), Int32(y), flip.cValue,
|
||||
other.pointer, Int32(otherX), Int32(otherY), otherFlip.cValue,
|
||||
rect.cValue) != 0
|
||||
}
|
||||
|
||||
// MARK: Drawing
|
||||
|
||||
/// Draws the bitmap with its upper-left corner at (x, y).
|
||||
public func draw(x: Int, y: Int, flip: BitmapFlip = .unflipped) {
|
||||
gfx.drawBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y), flip.cValue)
|
||||
}
|
||||
|
||||
/// Draws the bitmap scaled by (xScale, yScale) with its upper-left
|
||||
/// corner at (x, y).
|
||||
public func drawScaled(x: Int, y: Int, xScale: Float, yScale: Float) {
|
||||
gfx.drawScaledBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y), xScale, yScale)
|
||||
}
|
||||
|
||||
/// Draws the bitmap rotated by `degrees` around its anchor point,
|
||||
/// where (0.5, 0.5) is the center.
|
||||
public func drawRotated(x: Int, y: Int, degrees: Float,
|
||||
centerX: Float = 0.5, centerY: Float = 0.5,
|
||||
xScale: Float = 1, yScale: Float = 1) {
|
||||
gfx.drawRotatedBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y), degrees,
|
||||
centerX, centerY, xScale, yScale)
|
||||
}
|
||||
|
||||
/// Tiles the bitmap over the given area.
|
||||
public func tile(x: Int, y: Int, width: Int, height: Int, flip: BitmapFlip = .unflipped) {
|
||||
gfx.tileBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y),
|
||||
Int32(width), Int32(height), flip.cValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection of bitmaps loaded from an image table. Wraps `LCDBitmapTable`.
|
||||
public final class BitmapTable {
|
||||
let pointer: OpaquePointer
|
||||
|
||||
init(pointer: OpaquePointer) {
|
||||
self.pointer = pointer
|
||||
}
|
||||
|
||||
/// Allocates a table with room for `count` bitmaps of the given size.
|
||||
public convenience init(count: Int, width: Int, height: Int) {
|
||||
let pointer = gfx.newBitmapTable.unsafelyUnwrapped(Int32(count), Int32(width), Int32(height))
|
||||
self.init(pointer: pointer.unsafelyUnwrapped)
|
||||
}
|
||||
|
||||
/// Loads an image table from a file.
|
||||
public convenience init(path: String) throws(Playdate.Error) {
|
||||
var error: UnsafePointer<CChar>?
|
||||
let pointer = path.withPlaydateCString { gfx.loadBitmapTable.unsafelyUnwrapped($0, &error) }
|
||||
guard let pointer else { throw Playdate.Error(cString: error) }
|
||||
self.init(pointer: pointer)
|
||||
}
|
||||
|
||||
deinit {
|
||||
gfx.freeBitmapTable.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Replaces the table's contents with the image table at `path`.
|
||||
public func load(path: String) throws(Playdate.Error) {
|
||||
var error: UnsafePointer<CChar>?
|
||||
path.withPlaydateCString { gfx.loadIntoBitmapTable.unsafelyUnwrapped($0, pointer, &error) }
|
||||
if let error { throw Playdate.Error(cString: error) }
|
||||
}
|
||||
|
||||
/// The bitmap at `index`, or `nil` if out of range. The bitmap
|
||||
/// references storage owned by the table; keep the table alive while
|
||||
/// using it.
|
||||
public func bitmap(at index: Int) -> Bitmap? {
|
||||
guard let bitmap = gfx.getTableBitmap.unsafelyUnwrapped(pointer, Int32(index)) else {
|
||||
return nil
|
||||
}
|
||||
return Bitmap(pointer: bitmap, isOwned: false)
|
||||
}
|
||||
|
||||
/// The number of bitmaps in the table and the number of cells per row
|
||||
/// of the source image.
|
||||
public var info: (count: Int, cellsWide: Int) {
|
||||
var count: Int32 = 0, width: Int32 = 0
|
||||
gfx.getBitmapTableInfo.unsafelyUnwrapped(pointer, &count, &width)
|
||||
return (Int(count), Int(width))
|
||||
}
|
||||
|
||||
public var count: Int { info.count }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// GraphicsFont.swift
|
||||
// Font, FontPage, and Glyph wrappers around LCDFont / LCDFontPage / LCDFontGlyph.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
extension Playdate.Graphics {
|
||||
/// A font loaded from a .pft file. Wraps `LCDFont`.
|
||||
public final class Font {
|
||||
let pointer: OpaquePointer
|
||||
/// Fonts created from in-memory data reference that data; it is kept
|
||||
/// alive here.
|
||||
private let retainedData: UnsafeRawPointer?
|
||||
|
||||
init(pointer: OpaquePointer, retainedData: UnsafeRawPointer? = nil) {
|
||||
self.pointer = pointer
|
||||
self.retainedData = retainedData
|
||||
}
|
||||
|
||||
/// Loads a font from a file.
|
||||
public convenience init(path: String) throws(Playdate.Error) {
|
||||
var error: UnsafePointer<CChar>?
|
||||
let pointer = path.withPlaydateCString { gfx.loadFont.unsafelyUnwrapped($0, &error) }
|
||||
guard let pointer else { throw Playdate.Error(cString: error) }
|
||||
self.init(pointer: pointer)
|
||||
}
|
||||
|
||||
/// Creates a font from the contents of a .pft file already in memory.
|
||||
/// The bytes are copied and retained for the font's lifetime.
|
||||
public convenience init?(data: UnsafeRawBufferPointer, wide: Bool = false) {
|
||||
let copy = UnsafeMutableRawPointer.allocate(byteCount: data.count, alignment: 4)
|
||||
copy.copyMemory(from: data.baseAddress.unsafelyUnwrapped, byteCount: data.count)
|
||||
let fontData = OpaquePointer(copy)
|
||||
guard let pointer = gfx.makeFontFromData.unsafelyUnwrapped(
|
||||
fontData, wide ? 1 : 0, Int32(data.count)) else {
|
||||
copy.deallocate()
|
||||
return nil
|
||||
}
|
||||
self.init(pointer: pointer, retainedData: UnsafeRawPointer(copy))
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Per the C API docs, fonts are freed with the system allocator.
|
||||
Playdate.System.systemFree(UnsafeMutableRawPointer(pointer))
|
||||
retainedData?.deallocate()
|
||||
}
|
||||
|
||||
/// The font's glyph height in pixels.
|
||||
public var height: Int {
|
||||
Int(gfx.getFontHeight.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The width of `text` when drawn with this font.
|
||||
public func textWidth(_ text: String, tracking: Int = 0) -> Int {
|
||||
let utf8 = ContiguousArray(text.utf8)
|
||||
return utf8.withUnsafeBufferPointer { buffer in
|
||||
Int(gfx.getTextWidth.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count,
|
||||
kUTF8Encoding, Int32(tracking)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The height of `text` when wrapped to `maxWidth` with this font.
|
||||
public func textHeight(_ text: String, maxWidth: Int, wrap: TextWrappingMode = .word,
|
||||
tracking: Int = 0, extraLeading: Int = 0) -> Int {
|
||||
let utf8 = ContiguousArray(text.utf8)
|
||||
return utf8.withUnsafeBufferPointer { buffer in
|
||||
Int(gfx.getTextHeightForMaxWidth.unsafelyUnwrapped(
|
||||
pointer, buffer.baseAddress, buffer.count, Int32(maxWidth), kUTF8Encoding,
|
||||
wrap.cValue, Int32(tracking), Int32(extraLeading)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The page containing glyph data for the character `codepoint`
|
||||
/// belongs to. The page references data owned by the font.
|
||||
public func page(for codepoint: UInt32) -> FontPage? {
|
||||
guard let page = gfx.getFontPage.unsafelyUnwrapped(pointer, codepoint) else { return nil }
|
||||
return FontPage(pointer: page, font: self)
|
||||
}
|
||||
|
||||
/// The glyph for `codepoint`, with its bitmap and advance.
|
||||
/// The bitmap references data owned by the font.
|
||||
public func glyph(for codepoint: UInt32) -> (glyph: Glyph, bitmap: Bitmap?, advance: Int)? {
|
||||
var bitmap: OpaquePointer?
|
||||
var advance: Int32 = 0
|
||||
guard let glyph = gfx.getFontGlyph.unsafelyUnwrapped(pointer, codepoint, &bitmap, &advance) else {
|
||||
return nil
|
||||
}
|
||||
return (Glyph(pointer: glyph, font: self),
|
||||
bitmap.map { Bitmap(pointer: $0, isOwned: false) },
|
||||
Int(advance))
|
||||
}
|
||||
}
|
||||
|
||||
/// A page of glyphs within a font. Wraps `LCDFontPage`.
|
||||
/// Keep the font alive while using its pages.
|
||||
public struct FontPage {
|
||||
let pointer: OpaquePointer
|
||||
let font: Font
|
||||
|
||||
/// The glyph for `codepoint` within this page, with its bitmap and advance.
|
||||
public func glyph(for codepoint: UInt32) -> (glyph: Glyph, bitmap: Bitmap?, advance: Int)? {
|
||||
var bitmap: OpaquePointer?
|
||||
var advance: Int32 = 0
|
||||
guard let glyph = gfx.getPageGlyph.unsafelyUnwrapped(pointer, codepoint, &bitmap, &advance) else {
|
||||
return nil
|
||||
}
|
||||
return (Glyph(pointer: glyph, font: font),
|
||||
bitmap.map { Bitmap(pointer: $0, isOwned: false) },
|
||||
Int(advance))
|
||||
}
|
||||
}
|
||||
|
||||
/// A single glyph within a font. Wraps `LCDFontGlyph`.
|
||||
/// Keep the font alive while using its glyphs.
|
||||
public struct Glyph {
|
||||
let pointer: OpaquePointer
|
||||
let font: Font
|
||||
|
||||
/// The kerning adjustment between this glyph and the next character.
|
||||
public func kerning(glyphCode: UInt32, nextCode: UInt32) -> Int {
|
||||
Int(gfx.getGlyphKerning.unsafelyUnwrapped(pointer, glyphCode, nextCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// GraphicsTileMap.swift
|
||||
// TileMap wrapper around LCDTileMap (playdate->graphics->tilemap).
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
private var tilemapAPI: playdate_tilemap { gfx.tilemap.pointee }
|
||||
|
||||
extension Playdate.Graphics {
|
||||
/// A grid of tiles drawn from a bitmap table. Wraps `LCDTileMap`.
|
||||
public final class TileMap {
|
||||
let pointer: OpaquePointer
|
||||
/// The image table is retained so the tilemap's tiles stay valid.
|
||||
private var retainedImageTable: BitmapTable?
|
||||
|
||||
public init() {
|
||||
pointer = tilemapAPI.newTilemap.unsafelyUnwrapped().unsafelyUnwrapped
|
||||
}
|
||||
|
||||
deinit {
|
||||
tilemapAPI.freeTilemap.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// The bitmap table the tile indexes refer to.
|
||||
public var imageTable: BitmapTable? {
|
||||
get { retainedImageTable }
|
||||
set {
|
||||
retainedImageTable = newValue
|
||||
tilemapAPI.setImageTable.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the tilemap's size in tiles.
|
||||
public func setSize(tilesWide: Int, tilesHigh: Int) {
|
||||
tilemapAPI.setSize.unsafelyUnwrapped(pointer, Int32(tilesWide), Int32(tilesHigh))
|
||||
}
|
||||
|
||||
/// The tilemap's size in tiles.
|
||||
public var size: (tilesWide: Int, tilesHigh: Int) {
|
||||
var wide: Int32 = 0, high: Int32 = 0
|
||||
tilemapAPI.getSize.unsafelyUnwrapped(pointer, &wide, &high)
|
||||
return (Int(wide), Int(high))
|
||||
}
|
||||
|
||||
/// The tilemap's total size in pixels.
|
||||
public var pixelSize: (width: Int, height: Int) {
|
||||
var width: UInt32 = 0, height: UInt32 = 0
|
||||
tilemapAPI.getPixelSize.unsafelyUnwrapped(pointer, &width, &height)
|
||||
return (Int(width), Int(height))
|
||||
}
|
||||
|
||||
/// Fills the tilemap with `indexes`, `rowWidth` tiles per row. The
|
||||
/// tilemap is resized to fit.
|
||||
public func setTiles(_ indexes: [UInt16], rowWidth: Int) {
|
||||
var indexes = indexes
|
||||
indexes.withUnsafeMutableBufferPointer { buffer in
|
||||
tilemapAPI.setTiles.unsafelyUnwrapped(pointer, buffer.baseAddress,
|
||||
Int32(buffer.count), Int32(rowWidth))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the tile index at position (x, y).
|
||||
public func setTile(x: Int, y: Int, index: UInt16) {
|
||||
tilemapAPI.setTileAtPosition.unsafelyUnwrapped(pointer, Int32(x), Int32(y), index)
|
||||
}
|
||||
|
||||
/// The tile index at position (x, y), or `nil` if out of bounds.
|
||||
public func tile(x: Int, y: Int) -> Int? {
|
||||
let index = tilemapAPI.getTileAtPosition.unsafelyUnwrapped(pointer, Int32(x), Int32(y))
|
||||
return index < 0 ? nil : Int(index)
|
||||
}
|
||||
|
||||
/// Draws the tilemap with its upper-left corner at (x, y).
|
||||
public func draw(x: Float, y: Float) {
|
||||
tilemapAPI.drawAtPoint.unsafelyUnwrapped(pointer, x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// GraphicsVideo.swift
|
||||
// VideoPlayer and StreamPlayer wrappers around LCDVideoPlayer /
|
||||
// LCDStreamPlayer (playdate->graphics->video / ->videostream).
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
private var videoAPI: playdate_video { gfx.video.pointee }
|
||||
private var streamAPI: playdate_videostream { gfx.videostream.pointee }
|
||||
|
||||
extension Playdate.Graphics {
|
||||
/// Plays .pdv video files. Wraps `LCDVideoPlayer`.
|
||||
public final class VideoPlayer {
|
||||
let pointer: OpaquePointer
|
||||
let isOwned: Bool
|
||||
/// Retains the render context bitmap while the player uses it.
|
||||
private var retainedContext: Bitmap?
|
||||
|
||||
init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
/// Opens the .pdv file at `path`.
|
||||
public convenience init(path: String) throws(Playdate.Error) {
|
||||
let pointer = path.withPlaydateCString { videoAPI.loadVideo.unsafelyUnwrapped($0) }
|
||||
guard let pointer else {
|
||||
throw Playdate.Error(message: "unable to load video: \(path)")
|
||||
}
|
||||
self.init(pointer: pointer, isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
videoAPI.freePlayer.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the bitmap the video renders into.
|
||||
public func setContext(_ context: Bitmap) throws(Playdate.Error) {
|
||||
guard videoAPI.setContext.unsafelyUnwrapped(pointer, context.pointer) != 0 else {
|
||||
throw Playdate.Error(message: error ?? "unable to set video context")
|
||||
}
|
||||
retainedContext = context
|
||||
}
|
||||
|
||||
/// The bitmap the video renders into.
|
||||
public var context: Bitmap? {
|
||||
guard let context = videoAPI.getContext.unsafelyUnwrapped(pointer) else { return nil }
|
||||
return Bitmap(pointer: context, isOwned: false)
|
||||
}
|
||||
|
||||
/// Renders directly into the display framebuffer.
|
||||
public func useScreenContext() {
|
||||
retainedContext = nil
|
||||
videoAPI.useScreenContext.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Renders frame `frame` into the current context.
|
||||
public func renderFrame(_ frame: Int) throws(Playdate.Error) {
|
||||
guard videoAPI.renderFrame.unsafelyUnwrapped(pointer, Int32(frame)) != 0 else {
|
||||
throw Playdate.Error(message: error ?? "unable to render frame \(frame)")
|
||||
}
|
||||
}
|
||||
|
||||
/// The most recent error message, if any.
|
||||
public var error: String? {
|
||||
String(playdateCString: videoAPI.getError.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The video's dimensions, frame rate, frame count, and current frame.
|
||||
public var info: (width: Int, height: Int, frameRate: Float, frameCount: Int, currentFrame: Int) {
|
||||
var width: Int32 = 0, height: Int32 = 0, frameCount: Int32 = 0, currentFrame: Int32 = 0
|
||||
var frameRate: Float = 0
|
||||
videoAPI.getInfo.unsafelyUnwrapped(pointer, &width, &height, &frameRate,
|
||||
&frameCount, ¤tFrame)
|
||||
return (Int(width), Int(height), frameRate, Int(frameCount), Int(currentFrame))
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams video (and audio) from a file or network connection.
|
||||
/// Wraps `LCDStreamPlayer`.
|
||||
public final class StreamPlayer {
|
||||
let pointer: OpaquePointer
|
||||
/// Retains the active source so it outlives the stream.
|
||||
private var retainedSource: AnyObject?
|
||||
|
||||
public init() {
|
||||
pointer = streamAPI.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped
|
||||
}
|
||||
|
||||
deinit {
|
||||
streamAPI.freePlayer.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Sets the sizes of the stream's video and audio buffers, in bytes.
|
||||
public func setBufferSize(video: Int, audio: Int) {
|
||||
streamAPI.setBufferSize.unsafelyUnwrapped(pointer, Int32(video), Int32(audio))
|
||||
}
|
||||
|
||||
/// Streams from an open file.
|
||||
public func setFile(_ file: Playdate.File.Handle) {
|
||||
retainedSource = file
|
||||
streamAPI.setFile.unsafelyUnwrapped(pointer, file.pointer)
|
||||
}
|
||||
|
||||
/// Streams from an HTTP connection.
|
||||
public func setHTTPConnection(_ connection: Playdate.Network.HTTPConnection) {
|
||||
retainedSource = connection
|
||||
streamAPI.setHTTPConnection.unsafelyUnwrapped(pointer, connection.pointer)
|
||||
}
|
||||
|
||||
/// Streams from a TCP connection.
|
||||
public func setTCPConnection(_ connection: Playdate.Network.TCPConnection) {
|
||||
retainedSource = connection
|
||||
streamAPI.setTCPConnection.unsafelyUnwrapped(pointer, connection.pointer)
|
||||
}
|
||||
|
||||
/// The player used for the stream's audio track. Owned by the stream.
|
||||
public var filePlayer: Playdate.Sound.FilePlayer? {
|
||||
guard let player = streamAPI.getFilePlayer.unsafelyUnwrapped(pointer) else { return nil }
|
||||
return Playdate.Sound.FilePlayer(pointer: player, isOwned: false)
|
||||
}
|
||||
|
||||
/// The player used for the stream's video track. Owned by the stream.
|
||||
public var videoPlayer: VideoPlayer? {
|
||||
guard let player = streamAPI.getVideoPlayer.unsafelyUnwrapped(pointer) else { return nil }
|
||||
return VideoPlayer(pointer: player, isOwned: false)
|
||||
}
|
||||
|
||||
/// Advances the stream. Returns `true` if a frame was drawn.
|
||||
@discardableResult
|
||||
public func update() -> Bool {
|
||||
streamAPI.update.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// The number of video frames currently buffered.
|
||||
public var bufferedFrameCount: Int {
|
||||
Int(streamAPI.getBufferedFrameCount.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The total number of bytes read from the source.
|
||||
public var bytesRead: UInt32 {
|
||||
streamAPI.getBytesRead.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//
|
||||
// JSON.swift
|
||||
// Wraps `playdate->json` (pd_api_json.h).
|
||||
//
|
||||
// The C decoder is callback-based; this wrapper drives it to build a
|
||||
// complete `Value` tree. The encoder is exposed both as a streaming
|
||||
// `Encoder` and as a one-shot `encode(_:)` of a `Value`.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
private var jsonAPI: playdate_json { Playdate.api.json.pointee }
|
||||
|
||||
extension Playdate {
|
||||
/// The JSON API: decoding to and encoding from a `Value` tree.
|
||||
public enum JSON {}
|
||||
}
|
||||
|
||||
extension Playdate.JSON {
|
||||
/// A decoded JSON value.
|
||||
public indirect enum Value {
|
||||
case null
|
||||
case bool(Bool)
|
||||
case int(Int)
|
||||
case float(Float)
|
||||
case string(String)
|
||||
case array([Value])
|
||||
case table([String: Value])
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
private final class ValueBox {
|
||||
var value: Value
|
||||
init(_ value: Value) { self.value = value }
|
||||
}
|
||||
|
||||
private final class DecodeContext {
|
||||
/// Containers under construction, innermost last.
|
||||
var stack: [Value] = []
|
||||
var errorMessage: String?
|
||||
var errorLine: Int32 = 0
|
||||
|
||||
func append(_ value: Value, key: String?) {
|
||||
guard !stack.isEmpty else { return }
|
||||
switch stack[stack.count - 1] {
|
||||
case .array(var items):
|
||||
items.append(value)
|
||||
stack[stack.count - 1] = .array(items)
|
||||
case .table(var entries):
|
||||
if let key { entries[key] = value }
|
||||
stack[stack.count - 1] = .table(entries)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a C `json_value`, consuming any container box it references.
|
||||
private static func convert(_ value: json_value) -> Value {
|
||||
switch UInt32(bitPattern: Int32(value.type)) {
|
||||
case kJSONTrue.rawValue: return .bool(true)
|
||||
case kJSONFalse.rawValue: return .bool(false)
|
||||
case kJSONInteger.rawValue: return .int(Int(value.data.intval))
|
||||
case kJSONFloat.rawValue: return .float(value.data.floatval)
|
||||
case kJSONString.rawValue: return .string(String(playdateCString: value.data.stringval) ?? "")
|
||||
case kJSONArray.rawValue, kJSONTable.rawValue:
|
||||
guard let pointer = value.data.arrayval else { return .null }
|
||||
return Unmanaged<ValueBox>.fromOpaque(pointer).takeRetainedValue().value
|
||||
default: return .null
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeDecoder(context: Unmanaged<DecodeContext>) -> json_decoder {
|
||||
var decoder = json_decoder()
|
||||
decoder.userdata = context.toOpaque()
|
||||
decoder.decodeError = { decoder, error, linenum in
|
||||
guard let userdata = decoder?.pointee.userdata else { return }
|
||||
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
|
||||
context.errorMessage = String(playdateCString: error)
|
||||
context.errorLine = linenum
|
||||
}
|
||||
decoder.willDecodeSublist = { decoder, _, type in
|
||||
guard let userdata = decoder?.pointee.userdata else { return }
|
||||
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
|
||||
context.stack.append(type == kJSONArray ? .array([]) : .table([:]))
|
||||
}
|
||||
decoder.didDecodeTableValue = { decoder, key, value in
|
||||
guard let userdata = decoder?.pointee.userdata else { return }
|
||||
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
|
||||
context.append(Playdate.JSON.convert(value), key: String(playdateCString: key))
|
||||
}
|
||||
decoder.didDecodeArrayValue = { decoder, _, value in
|
||||
guard let userdata = decoder?.pointee.userdata else { return }
|
||||
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
|
||||
context.append(Playdate.JSON.convert(value), key: nil)
|
||||
}
|
||||
decoder.didDecodeSublist = { decoder, _, _ in
|
||||
guard let userdata = decoder?.pointee.userdata else { return nil }
|
||||
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
|
||||
guard let finished = context.stack.popLast() else { return nil }
|
||||
// Handed to the parent container (or the decode outval) as the
|
||||
// sublist's value; consumed by `convert`.
|
||||
return Unmanaged.passRetained(ValueBox(finished)).toOpaque()
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
|
||||
/// Decodes a JSON string into a `Value` tree.
|
||||
public static func decode(_ jsonString: String) throws(Playdate.Error) -> Value {
|
||||
let context = DecodeContext()
|
||||
let unmanaged = Unmanaged.passUnretained(context)
|
||||
var decoder = makeDecoder(context: unmanaged)
|
||||
var outval = json_value()
|
||||
let ok = jsonString.withPlaydateCString { cString in
|
||||
withExtendedLifetime(context) {
|
||||
jsonAPI.decodeString.unsafelyUnwrapped(&decoder, cString, &outval) != 0
|
||||
}
|
||||
}
|
||||
guard ok else {
|
||||
throw decodeError(context)
|
||||
}
|
||||
return convert(outval)
|
||||
}
|
||||
|
||||
/// Decodes JSON read from an open file into a `Value` tree.
|
||||
public static func decode(file: Playdate.File.Handle) throws(Playdate.Error) -> Value {
|
||||
let context = DecodeContext()
|
||||
var decoder = makeDecoder(context: Unmanaged.passUnretained(context))
|
||||
var reader = json_reader()
|
||||
reader.userdata = Unmanaged.passUnretained(file).toOpaque()
|
||||
reader.read = { userdata, buffer, size in
|
||||
guard let userdata, let buffer else { return -1 }
|
||||
let file = Unmanaged<Playdate.File.Handle>.fromOpaque(userdata).takeUnretainedValue()
|
||||
let destination = UnsafeMutableRawBufferPointer(start: buffer, count: Int(size))
|
||||
do {
|
||||
let count = try file.read(into: destination)
|
||||
return count > 0 ? Int32(count) : -1
|
||||
} catch {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
var outval = json_value()
|
||||
let ok = withExtendedLifetime(context) {
|
||||
withExtendedLifetime(file) {
|
||||
jsonAPI.decode.unsafelyUnwrapped(&decoder, reader, &outval) != 0
|
||||
}
|
||||
}
|
||||
guard ok else {
|
||||
throw decodeError(context)
|
||||
}
|
||||
return convert(outval)
|
||||
}
|
||||
|
||||
/// Opens and decodes the JSON file at `path`.
|
||||
public static func decodeFile(at path: String) throws(Playdate.Error) -> Value {
|
||||
let file = try Playdate.File.Handle(path: path, mode: [.read, .readData])
|
||||
return try decode(file: file)
|
||||
}
|
||||
|
||||
private static func decodeError(_ context: DecodeContext) -> Playdate.Error {
|
||||
Playdate.Error(message: "\(context.errorMessage ?? "JSON decode failed") (line \(context.errorLine))")
|
||||
}
|
||||
|
||||
// MARK: - Encoding
|
||||
|
||||
/// A streaming JSON encoder writing into a string. Wraps `json_encoder`.
|
||||
public final class Encoder {
|
||||
private final class Output {
|
||||
var text = ""
|
||||
}
|
||||
|
||||
private var encoder = json_encoder()
|
||||
private let output = Output()
|
||||
|
||||
public init(pretty: Bool = false) {
|
||||
jsonAPI.initEncoder.unsafelyUnwrapped(&encoder, { userdata, string, length in
|
||||
guard let userdata, let string else { return }
|
||||
let output = Unmanaged<Output>.fromOpaque(userdata).takeUnretainedValue()
|
||||
let bytes = UnsafeRawBufferPointer(start: string, count: Int(length))
|
||||
output.text += String(decoding: bytes, as: UTF8.self)
|
||||
}, Unmanaged.passUnretained(output).toOpaque(), pretty ? 1 : 0)
|
||||
}
|
||||
|
||||
/// The JSON produced so far.
|
||||
public var json: String { output.text }
|
||||
|
||||
public func startArray() {
|
||||
withUnsafeMutablePointer(to: &encoder) { $0.pointee.startArray.unsafelyUnwrapped($0) }
|
||||
}
|
||||
|
||||
/// Call before writing each array element.
|
||||
public func addArrayMember() {
|
||||
withUnsafeMutablePointer(to: &encoder) { $0.pointee.addArrayMember.unsafelyUnwrapped($0) }
|
||||
}
|
||||
|
||||
public func endArray() {
|
||||
withUnsafeMutablePointer(to: &encoder) { $0.pointee.endArray.unsafelyUnwrapped($0) }
|
||||
}
|
||||
|
||||
public func startTable() {
|
||||
withUnsafeMutablePointer(to: &encoder) { $0.pointee.startTable.unsafelyUnwrapped($0) }
|
||||
}
|
||||
|
||||
/// Call before writing each table value.
|
||||
public func addTableMember(name: String) {
|
||||
name.withPlaydateCString { cName in
|
||||
withUnsafeMutablePointer(to: &encoder) {
|
||||
$0.pointee.addTableMember.unsafelyUnwrapped($0, cName, Int32(name.utf8.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func endTable() {
|
||||
withUnsafeMutablePointer(to: &encoder) { $0.pointee.endTable.unsafelyUnwrapped($0) }
|
||||
}
|
||||
|
||||
public func writeNull() {
|
||||
withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeNull.unsafelyUnwrapped($0) }
|
||||
}
|
||||
|
||||
public func writeBool(_ value: Bool) {
|
||||
withUnsafeMutablePointer(to: &encoder) {
|
||||
(value ? $0.pointee.writeTrue : $0.pointee.writeFalse).unsafelyUnwrapped($0)
|
||||
}
|
||||
}
|
||||
|
||||
public func writeInt(_ value: Int) {
|
||||
withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeInt.unsafelyUnwrapped($0, Int32(value)) }
|
||||
}
|
||||
|
||||
public func writeDouble(_ value: Double) {
|
||||
withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeDouble.unsafelyUnwrapped($0, value) }
|
||||
}
|
||||
|
||||
public func writeString(_ value: String) {
|
||||
value.withPlaydateCString { cString in
|
||||
withUnsafeMutablePointer(to: &encoder) {
|
||||
$0.pointee.writeString.unsafelyUnwrapped($0, cString, Int32(value.utf8.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a complete `Value` tree.
|
||||
public func write(_ value: Value) {
|
||||
switch value {
|
||||
case .null:
|
||||
writeNull()
|
||||
case .bool(let bool):
|
||||
writeBool(bool)
|
||||
case .int(let int):
|
||||
writeInt(int)
|
||||
case .float(let float):
|
||||
writeDouble(Double(float))
|
||||
case .string(let string):
|
||||
writeString(string)
|
||||
case .array(let items):
|
||||
startArray()
|
||||
for item in items {
|
||||
addArrayMember()
|
||||
write(item)
|
||||
}
|
||||
endArray()
|
||||
case .table(let entries):
|
||||
startTable()
|
||||
for (key, entry) in entries {
|
||||
addTableMember(name: key)
|
||||
write(entry)
|
||||
}
|
||||
endTable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes a `Value` tree as a JSON string.
|
||||
public static func encode(_ value: Value, pretty: Bool = false) -> String {
|
||||
let encoder = Encoder(pretty: pretty)
|
||||
encoder.write(value)
|
||||
return encoder.json
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//
|
||||
// Lua.swift
|
||||
// Wraps `playdate->lua` (pd_api_lua.h).
|
||||
//
|
||||
// Lua callbacks are C function pointers without userdata, so functions
|
||||
// registered here must be `@convention(c)` (the `CFunction` typealias), not
|
||||
// capturing closures.
|
||||
//
|
||||
|
||||
public import CPlaydate
|
||||
|
||||
private var luaAPI: playdate_lua { Playdate.api.lua.pointee }
|
||||
|
||||
extension Playdate {
|
||||
/// The Lua bridge: registering C functions and classes, and exchanging
|
||||
/// values with Lua code.
|
||||
public enum Lua {}
|
||||
}
|
||||
|
||||
extension Playdate.Lua {
|
||||
/// A function callable from Lua. Returns the number of values it pushed
|
||||
/// onto the stack.
|
||||
public typealias CFunction = lua_CFunction
|
||||
|
||||
/// The type of a value on the Lua stack.
|
||||
public enum Kind: UInt32, Sendable {
|
||||
case `nil` = 0
|
||||
case bool = 1
|
||||
case int = 2
|
||||
case float = 3
|
||||
case string = 4
|
||||
case table = 5
|
||||
case function = 6
|
||||
case thread = 7
|
||||
case object = 8
|
||||
|
||||
init(_ type: LuaType) { self = Kind(rawValue: type.rawValue) ?? .nil }
|
||||
}
|
||||
|
||||
/// A constant published on a registered class.
|
||||
public enum ClassValue {
|
||||
case int(name: String, value: UInt32)
|
||||
case float(name: String, value: Float)
|
||||
case string(name: String, value: String)
|
||||
}
|
||||
|
||||
/// Buffers passed to `registerClass`/`addFunction`; the OS may keep
|
||||
/// referencing them, so they are retained for the life of the game.
|
||||
nonisolated(unsafe) private static var retainedBuffers: [UnsafeMutableRawPointer] = []
|
||||
|
||||
private static func retainedCString(_ string: String) -> UnsafePointer<CChar> {
|
||||
let copy = string.copiedPlaydateCString()
|
||||
retainedBuffers.append(UnsafeMutableRawPointer(copy))
|
||||
return UnsafePointer(copy)
|
||||
}
|
||||
|
||||
// MARK: - Registration
|
||||
|
||||
/// Makes `function` callable from Lua as `name` (which may contain dots
|
||||
/// for namespacing, e.g. "mylib.myfunc").
|
||||
public static func addFunction(_ function: CFunction, name: String) throws(Playdate.Error) {
|
||||
var error: UnsafePointer<CChar>?
|
||||
let ok = name.withPlaydateCString {
|
||||
luaAPI.addFunction.unsafelyUnwrapped(function, $0, &error) != 0
|
||||
}
|
||||
if !ok { throw Playdate.Error(cString: error) }
|
||||
}
|
||||
|
||||
/// Registers a Lua class named `name` with the given methods and
|
||||
/// constants. When `isStatic` is `true` a plain table of functions is
|
||||
/// created instead of a class.
|
||||
public static func registerClass(name: String,
|
||||
functions: [(name: String, function: CFunction)],
|
||||
values: [ClassValue] = [],
|
||||
isStatic: Bool = false) throws(Playdate.Error) {
|
||||
// The registration tables are kept alive permanently: the OS
|
||||
// documents no copying guarantees for them.
|
||||
var registrations: [lua_reg] = functions.map { entry in
|
||||
lua_reg(name: retainedCString(entry.name), func: entry.function)
|
||||
}
|
||||
registrations.append(lua_reg(name: nil, func: nil))
|
||||
|
||||
var constants: [lua_val] = values.map { value in
|
||||
switch value {
|
||||
case .int(let name, let intValue):
|
||||
return lua_val(name: retainedCString(name), type: kInt, v: .init(intval: intValue))
|
||||
case .float(let name, let floatValue):
|
||||
return lua_val(name: retainedCString(name), type: kFloat, v: .init(floatval: floatValue))
|
||||
case .string(let name, let stringValue):
|
||||
return lua_val(name: retainedCString(name), type: kStr,
|
||||
v: .init(strval: retainedCString(stringValue)))
|
||||
}
|
||||
}
|
||||
constants.append(lua_val(name: nil, type: kInt, v: .init(intval: 0)))
|
||||
|
||||
let registrationsBuffer = UnsafeMutablePointer<lua_reg>.allocate(capacity: registrations.count)
|
||||
registrationsBuffer.initialize(from: registrations, count: registrations.count)
|
||||
retainedBuffers.append(UnsafeMutableRawPointer(registrationsBuffer))
|
||||
|
||||
let constantsBuffer = UnsafeMutablePointer<lua_val>.allocate(capacity: constants.count)
|
||||
constantsBuffer.initialize(from: constants, count: constants.count)
|
||||
retainedBuffers.append(UnsafeMutableRawPointer(constantsBuffer))
|
||||
|
||||
var error: UnsafePointer<CChar>?
|
||||
let ok = name.withPlaydateCString {
|
||||
luaAPI.registerClass.unsafelyUnwrapped($0, registrationsBuffer,
|
||||
values.isEmpty ? nil : constantsBuffer,
|
||||
isStatic ? 1 : 0, &error) != 0
|
||||
}
|
||||
if !ok { throw Playdate.Error(cString: error) }
|
||||
}
|
||||
|
||||
/// Pushes a function onto the stack, e.g. for `setUserValue`.
|
||||
public static func pushFunction(_ function: CFunction) {
|
||||
luaAPI.pushFunction.unsafelyUnwrapped(function)
|
||||
}
|
||||
|
||||
/// From a class's `__index` callback: looks up the key in the instance
|
||||
/// metatable first. Returns 1 if a value was found.
|
||||
public static func indexMetatable() -> Bool {
|
||||
luaAPI.indexMetatable.unsafelyUnwrapped() != 0
|
||||
}
|
||||
|
||||
/// Pauses the Lua runtime.
|
||||
public static func stop() {
|
||||
luaAPI.stop.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
/// Resumes the Lua runtime.
|
||||
public static func start() {
|
||||
luaAPI.start.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
// MARK: - Arguments
|
||||
|
||||
/// The number of arguments the Lua caller passed. Positions are 1-based.
|
||||
public static var argumentCount: Int {
|
||||
Int(luaAPI.getArgCount.unsafelyUnwrapped())
|
||||
}
|
||||
|
||||
/// The type of the argument at 1-based `position`; for objects, also the
|
||||
/// class name.
|
||||
public static func argumentType(at position: Int) -> (kind: Kind, className: String?) {
|
||||
var className: UnsafePointer<CChar>?
|
||||
let type = luaAPI.getArgType.unsafelyUnwrapped(Int32(position), &className)
|
||||
return (Kind(type), String(playdateCString: className))
|
||||
}
|
||||
|
||||
public static func argumentIsNil(at position: Int) -> Bool {
|
||||
luaAPI.argIsNil.unsafelyUnwrapped(Int32(position)) != 0
|
||||
}
|
||||
|
||||
public static func boolArgument(at position: Int) -> Bool {
|
||||
luaAPI.getArgBool.unsafelyUnwrapped(Int32(position)) != 0
|
||||
}
|
||||
|
||||
public static func intArgument(at position: Int) -> Int {
|
||||
Int(luaAPI.getArgInt.unsafelyUnwrapped(Int32(position)))
|
||||
}
|
||||
|
||||
public static func floatArgument(at position: Int) -> Float {
|
||||
luaAPI.getArgFloat.unsafelyUnwrapped(Int32(position))
|
||||
}
|
||||
|
||||
public static func stringArgument(at position: Int) -> String? {
|
||||
String(playdateCString: luaAPI.getArgString.unsafelyUnwrapped(Int32(position)))
|
||||
}
|
||||
|
||||
/// The argument as raw bytes (which may contain embedded zeros).
|
||||
public static func bytesArgument(at position: Int) -> [UInt8]? {
|
||||
var length = 0
|
||||
guard let bytes = luaAPI.getArgBytes.unsafelyUnwrapped(Int32(position), &length) else {
|
||||
return nil
|
||||
}
|
||||
let buffer = UnsafeRawBufferPointer(start: bytes, count: length)
|
||||
return [UInt8](buffer)
|
||||
}
|
||||
|
||||
/// The argument as an object instance of class `type`, with the
|
||||
/// `UDObject` handle for retaining it.
|
||||
public static func objectArgument(at position: Int, type: String)
|
||||
-> (object: UnsafeMutableRawPointer?, userdataObject: UDObject?) {
|
||||
let cType = type.copiedPlaydateCString()
|
||||
defer { cType.deallocate() }
|
||||
var userdataObject: OpaquePointer?
|
||||
let object = luaAPI.getArgObject.unsafelyUnwrapped(Int32(position), cType, &userdataObject)
|
||||
return (object, userdataObject.map { UDObject(pointer: $0) })
|
||||
}
|
||||
|
||||
/// The argument as a bitmap. References an object owned by Lua; retain
|
||||
/// the Lua value while using it.
|
||||
public static func bitmapArgument(at position: Int) -> Playdate.Graphics.Bitmap? {
|
||||
guard let bitmap = luaAPI.getBitmap.unsafelyUnwrapped(Int32(position)) else { return nil }
|
||||
return Playdate.Graphics.Bitmap(pointer: bitmap, isOwned: false)
|
||||
}
|
||||
|
||||
/// The argument as a sprite.
|
||||
public static func spriteArgument(at position: Int) -> Playdate.Sprite? {
|
||||
guard let sprite = luaAPI.getSprite.unsafelyUnwrapped(Int32(position)) else { return nil }
|
||||
return Playdate.Sprite.wrapper(for: sprite)
|
||||
}
|
||||
|
||||
// MARK: - Return values
|
||||
|
||||
public static func pushNil() {
|
||||
luaAPI.pushNil.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
public static func push(_ value: Bool) {
|
||||
luaAPI.pushBool.unsafelyUnwrapped(value ? 1 : 0)
|
||||
}
|
||||
|
||||
public static func push(_ value: Int) {
|
||||
luaAPI.pushInt.unsafelyUnwrapped(Int32(value))
|
||||
}
|
||||
|
||||
public static func push(_ value: Float) {
|
||||
luaAPI.pushFloat.unsafelyUnwrapped(value)
|
||||
}
|
||||
|
||||
public static func push(_ value: String) {
|
||||
value.withPlaydateCString { luaAPI.pushString.unsafelyUnwrapped($0) }
|
||||
}
|
||||
|
||||
public static func push(bytes: [UInt8]) {
|
||||
bytes.withUnsafeBytes { buffer in
|
||||
luaAPI.pushBytes.unsafelyUnwrapped(
|
||||
buffer.baseAddress?.assumingMemoryBound(to: CChar.self), buffer.count)
|
||||
}
|
||||
}
|
||||
|
||||
public static func push(_ bitmap: Playdate.Graphics.Bitmap) {
|
||||
luaAPI.pushBitmap.unsafelyUnwrapped(bitmap.pointer)
|
||||
}
|
||||
|
||||
public static func push(_ sprite: Playdate.Sprite) {
|
||||
luaAPI.pushSprite.unsafelyUnwrapped(sprite.pointer)
|
||||
}
|
||||
|
||||
/// Wraps `object` in a Lua instance of class `type` and pushes it, with
|
||||
/// `valueCount` extra user-value slots.
|
||||
@discardableResult
|
||||
public static func pushObject(_ object: UnsafeMutableRawPointer, type: String,
|
||||
valueCount: Int = 0) -> UDObject? {
|
||||
let cType = type.copiedPlaydateCString()
|
||||
defer { cType.deallocate() }
|
||||
guard let pointer = luaAPI.pushObject.unsafelyUnwrapped(object, cType, Int32(valueCount)) else {
|
||||
return nil
|
||||
}
|
||||
return UDObject(pointer: pointer)
|
||||
}
|
||||
|
||||
/// A handle to a Lua-owned object. Wraps `LuaUDObject`.
|
||||
public struct UDObject {
|
||||
let pointer: OpaquePointer
|
||||
|
||||
/// Prevents the object from being garbage-collected until `release()`.
|
||||
@discardableResult
|
||||
public func retain() -> UDObject {
|
||||
UDObject(pointer: luaAPI.retainObject.unsafelyUnwrapped(pointer).unsafelyUnwrapped)
|
||||
}
|
||||
|
||||
public func release() {
|
||||
luaAPI.releaseObject.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Pops the value on top of the stack and stores it in the object's
|
||||
/// user-value `slot` (1-based).
|
||||
public func setUserValue(slot: UInt32) {
|
||||
luaAPI.setUserValue.unsafelyUnwrapped(pointer, slot)
|
||||
}
|
||||
|
||||
/// Pushes the value in user-value `slot` onto the stack and returns
|
||||
/// its stack position, or `nil` if there is none.
|
||||
@discardableResult
|
||||
public func getUserValue(slot: UInt32) -> Int? {
|
||||
let position = luaAPI.getUserValue.unsafelyUnwrapped(pointer, slot)
|
||||
return position == 0 ? nil : Int(position)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calling Lua
|
||||
|
||||
/// Calls the Lua function `name`. Push the arguments onto the stack
|
||||
/// first. Calling Lua from Swift has overhead; use sparingly.
|
||||
public static func callFunction(_ name: String, argumentCount: Int = 0) throws(Playdate.Error) {
|
||||
var error: UnsafePointer<CChar>?
|
||||
let ok = name.withPlaydateCString {
|
||||
luaAPI.callFunction.unsafelyUnwrapped($0, Int32(argumentCount), &error) != 0
|
||||
}
|
||||
if !ok { throw Playdate.Error(cString: error) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
//
|
||||
// Network.swift
|
||||
// Wraps `playdate->network` (pd_api_network.h): HTTP and TCP connections.
|
||||
//
|
||||
// The binding stores a back-reference to each connection wrapper in the
|
||||
// underlying object's userdata slot so callbacks can recover the wrapper;
|
||||
// the C userdata slot is therefore reserved by the binding.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
private var networkAPI: playdate_network { Playdate.api.network.pointee }
|
||||
private var httpAPI: playdate_http { networkAPI.http.pointee }
|
||||
private var tcpAPI: playdate_tcp { networkAPI.tcp.pointee }
|
||||
|
||||
extension Playdate {
|
||||
/// The network API: wifi status, HTTP, and TCP.
|
||||
public enum Network {}
|
||||
}
|
||||
|
||||
extension Playdate.Network {
|
||||
public typealias AccessReply = Playdate.AccessReply
|
||||
|
||||
/// A network error code (`PDNetErr`).
|
||||
public enum NetError: Int32, Swift.Error, Sendable {
|
||||
case noDevice = -1
|
||||
case busy = -2
|
||||
case writeError = -3
|
||||
case writeBusy = -4
|
||||
case writeTimeout = -5
|
||||
case readError = -6
|
||||
case readBusy = -7
|
||||
case readTimeout = -8
|
||||
case readOverflow = -9
|
||||
case frameError = -10
|
||||
case badResponse = -11
|
||||
case errorResponse = -12
|
||||
case resetTimeout = -13
|
||||
case bufferTooSmall = -14
|
||||
case unexpectedResponse = -15
|
||||
case notConnectedToAP = -16
|
||||
case notImplemented = -17
|
||||
case connectionClosed = -18
|
||||
case unknown = 1
|
||||
|
||||
init(_ error: PDNetErr) {
|
||||
self = NetError(rawValue: error.rawValue) ?? .unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws unless `error` is `NET_OK`.
|
||||
static func check(_ error: PDNetErr) throws(NetError) {
|
||||
if error != NET_OK {
|
||||
throw NetError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an error code to `nil` (OK) or a `NetError`.
|
||||
static func optionalError(_ error: PDNetErr) -> NetError? {
|
||||
error == NET_OK ? nil : NetError(error)
|
||||
}
|
||||
|
||||
/// The device's wifi status.
|
||||
public enum WifiStatus: UInt32, Sendable {
|
||||
case notConnected = 0
|
||||
case connected = 1
|
||||
/// A connection was attempted but no configured access point was
|
||||
/// available.
|
||||
case notAvailable = 2
|
||||
}
|
||||
|
||||
public static var status: WifiStatus {
|
||||
WifiStatus(rawValue: networkAPI.getStatus.unsafelyUnwrapped().rawValue) ?? .notConnected
|
||||
}
|
||||
|
||||
/// Turns the wifi radio on or off. The completion receives `nil` on
|
||||
/// success.
|
||||
public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) {
|
||||
setEnabledCompletion = completion
|
||||
if completion != nil {
|
||||
networkAPI.setEnabled.unsafelyUnwrapped(enabled, { error in
|
||||
let completion = Playdate.Network.setEnabledCompletion
|
||||
Playdate.Network.setEnabledCompletion = nil
|
||||
completion?(Playdate.Network.optionalError(error))
|
||||
})
|
||||
} else {
|
||||
networkAPI.setEnabled.unsafelyUnwrapped(enabled, nil)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var setEnabledCompletion: ((NetError?) -> Void)?
|
||||
|
||||
/// Requests permission to connect to `server`. Shared by HTTP and TCP.
|
||||
fileprivate static func requestAccess(
|
||||
rawRequest: (UnsafePointer<CChar>?, Int32, Bool, UnsafePointer<CChar>?,
|
||||
(@convention(c) (Bool, UnsafeMutableRawPointer?) -> Void)?,
|
||||
UnsafeMutableRawPointer?) -> accessReply,
|
||||
server: String, port: Int, useSSL: Bool, purpose: String?,
|
||||
completion: @escaping (Bool) -> Void) -> AccessReply {
|
||||
final class Box {
|
||||
let body: (Bool) -> Void
|
||||
init(_ body: @escaping (Bool) -> Void) { self.body = body }
|
||||
}
|
||||
let box = Unmanaged.passRetained(Box(completion))
|
||||
let trampoline: @convention(c) (Bool, UnsafeMutableRawPointer?) -> Void = { allowed, userdata in
|
||||
guard let userdata else { return }
|
||||
Unmanaged<Box>.fromOpaque(userdata).takeRetainedValue().body(allowed)
|
||||
}
|
||||
let reply = server.withPlaydateCString { cServer in
|
||||
if let purpose {
|
||||
return purpose.withPlaydateCString { cPurpose in
|
||||
rawRequest(cServer, Int32(port), useSSL, cPurpose, trampoline, box.toOpaque())
|
||||
}
|
||||
} else {
|
||||
return rawRequest(cServer, Int32(port), useSSL, nil, trampoline, box.toOpaque())
|
||||
}
|
||||
}
|
||||
if reply != kAccessAsk {
|
||||
// The callback will not be invoked; balance the retain.
|
||||
box.release()
|
||||
}
|
||||
return AccessReply(rawValue: reply.rawValue) ?? .ask
|
||||
}
|
||||
|
||||
// MARK: - HTTP
|
||||
|
||||
/// An HTTP connection to a server. Wraps `HTTPConnection`.
|
||||
public final class HTTPConnection {
|
||||
let pointer: OpaquePointer
|
||||
|
||||
var headerReceivedCallback: ((HTTPConnection, _ key: String, _ value: String) -> Void)?
|
||||
var headersReadCallback: ((HTTPConnection) -> Void)?
|
||||
var responseCallback: ((HTTPConnection) -> Void)?
|
||||
var requestCompleteCallback: ((HTTPConnection) -> Void)?
|
||||
var connectionClosedCallback: ((HTTPConnection) -> Void)?
|
||||
|
||||
/// Requests permission to connect to `server`. If the reply is
|
||||
/// `.ask`, the completion is called later with the user's answer.
|
||||
@discardableResult
|
||||
public static func requestAccess(server: String, port: Int = 443, useSSL: Bool = true,
|
||||
purpose: String? = nil,
|
||||
completion: @escaping (Bool) -> Void) -> AccessReply {
|
||||
Playdate.Network.requestAccess(
|
||||
rawRequest: { httpAPI.requestAccess.unsafelyUnwrapped($0, $1, $2, $3, $4, $5) },
|
||||
server: server, port: port, useSSL: useSSL, purpose: purpose,
|
||||
completion: completion)
|
||||
}
|
||||
|
||||
/// Opens a connection to `server`. Fails if access has not been
|
||||
/// granted.
|
||||
public init?(server: String, port: Int = 443, useSSL: Bool = true) {
|
||||
let pointer = server.withPlaydateCString {
|
||||
httpAPI.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL)
|
||||
}
|
||||
guard let pointer else { return nil }
|
||||
self.pointer = pointer
|
||||
httpAPI.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque())
|
||||
}
|
||||
|
||||
deinit {
|
||||
httpAPI.setUserdata.unsafelyUnwrapped(pointer, nil)
|
||||
httpAPI.release.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
private static func wrapper(for pointer: OpaquePointer?) -> HTTPConnection? {
|
||||
guard let pointer,
|
||||
let userdata = httpAPI.getUserdata.unsafelyUnwrapped(pointer) else { return nil }
|
||||
return Unmanaged<HTTPConnection>.fromOpaque(userdata).takeUnretainedValue()
|
||||
}
|
||||
|
||||
// MARK: Configuration
|
||||
|
||||
/// The time to wait for the connection to open, in milliseconds.
|
||||
public func setConnectTimeout(milliseconds: Int) {
|
||||
httpAPI.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
|
||||
}
|
||||
|
||||
/// Whether to keep the connection open after a request completes.
|
||||
public func setKeepAlive(_ keepAlive: Bool) {
|
||||
httpAPI.setKeepAlive.unsafelyUnwrapped(pointer, keepAlive)
|
||||
}
|
||||
|
||||
/// Adds a `Range: bytes=start-end` header to future requests.
|
||||
public func setByteRange(start: Int, end: Int) {
|
||||
httpAPI.setByteRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end))
|
||||
}
|
||||
|
||||
/// The time to wait for incoming data, in milliseconds.
|
||||
public func setReadTimeout(milliseconds: Int) {
|
||||
httpAPI.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
|
||||
}
|
||||
|
||||
/// The size of the connection's read buffer, in bytes.
|
||||
public func setReadBufferSize(bytes: Int) {
|
||||
httpAPI.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes))
|
||||
}
|
||||
|
||||
// MARK: Requests
|
||||
|
||||
/// Sends a GET request for `path`. `headers` are raw header lines
|
||||
/// (e.g. "Accept: text/html\r\n").
|
||||
public func get(path: String, headers: String = "") throws(NetError) {
|
||||
let error = path.withPlaydateCString { cPath in
|
||||
headers.withPlaydateCString { cHeaders in
|
||||
httpAPI.get.unsafelyUnwrapped(pointer, cPath, cHeaders, headers.utf8.count)
|
||||
}
|
||||
}
|
||||
try Playdate.Network.check(error)
|
||||
}
|
||||
|
||||
/// Sends a POST request for `path` with the given body.
|
||||
public func post(path: String, headers: String = "", body: [UInt8]) throws(NetError) {
|
||||
let error = path.withPlaydateCString { cPath in
|
||||
headers.withPlaydateCString { cHeaders in
|
||||
body.withUnsafeBytes { bodyBuffer in
|
||||
httpAPI.post.unsafelyUnwrapped(
|
||||
pointer, cPath, cHeaders, headers.utf8.count,
|
||||
bodyBuffer.baseAddress?.assumingMemoryBound(to: CChar.self),
|
||||
bodyBuffer.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
try Playdate.Network.check(error)
|
||||
}
|
||||
|
||||
/// Sends a request with an arbitrary HTTP method.
|
||||
public func query(method: String, path: String, headers: String = "",
|
||||
body: [UInt8] = []) throws(NetError) {
|
||||
let error = method.withPlaydateCString { cMethod in
|
||||
path.withPlaydateCString { cPath in
|
||||
headers.withPlaydateCString { cHeaders in
|
||||
body.withUnsafeBytes { bodyBuffer in
|
||||
httpAPI.query.unsafelyUnwrapped(
|
||||
pointer, cMethod, cPath, cHeaders, headers.utf8.count,
|
||||
bodyBuffer.baseAddress?.assumingMemoryBound(to: CChar.self),
|
||||
bodyBuffer.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try Playdate.Network.check(error)
|
||||
}
|
||||
|
||||
// MARK: Response
|
||||
|
||||
/// The last error on the connection, if any.
|
||||
public var error: NetError? {
|
||||
Playdate.Network.optionalError(httpAPI.getError.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The number of bytes read of the current response, and the total
|
||||
/// expected (0 if the response has no Content-Length).
|
||||
public var progress: (read: Int, total: Int) {
|
||||
var read: Int32 = 0, total: Int32 = 0
|
||||
httpAPI.getProgress.unsafelyUnwrapped(pointer, &read, &total)
|
||||
return (Int(read), Int(total))
|
||||
}
|
||||
|
||||
/// The HTTP status code of the response.
|
||||
public var responseStatus: Int {
|
||||
Int(httpAPI.getResponseStatus.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The number of response bytes available to read.
|
||||
public var bytesAvailable: Int {
|
||||
Int(httpAPI.getBytesAvailable.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// Reads up to `buffer.count` response bytes. Returns the number of
|
||||
/// bytes read.
|
||||
public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int {
|
||||
let result = httpAPI.read.unsafelyUnwrapped(pointer, buffer.baseAddress,
|
||||
UInt32(buffer.count))
|
||||
if result < 0 {
|
||||
throw NetError(rawValue: result) ?? .unknown
|
||||
}
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
/// Reads up to `length` available response bytes.
|
||||
public func read(length: Int) throws(NetError) -> [UInt8] {
|
||||
var bytes = [UInt8](repeating: 0, count: length)
|
||||
let result = bytes.withUnsafeMutableBytes { buffer in
|
||||
httpAPI.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count))
|
||||
}
|
||||
if result < 0 {
|
||||
throw NetError(rawValue: result) ?? .unknown
|
||||
}
|
||||
bytes.removeLast(length - Int(result))
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// Closes the connection.
|
||||
public func close() {
|
||||
httpAPI.close.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
// MARK: Callbacks
|
||||
|
||||
/// Called for each header line as it arrives.
|
||||
public func setHeaderReceivedCallback(_ callback: ((HTTPConnection, _ key: String, _ value: String) -> Void)?) {
|
||||
headerReceivedCallback = callback
|
||||
if callback != nil {
|
||||
httpAPI.setHeaderReceivedCallback.unsafelyUnwrapped(pointer, { connection, key, value in
|
||||
guard let wrapper = HTTPConnection.wrapper(for: connection),
|
||||
let key = String(playdateCString: key),
|
||||
let value = String(playdateCString: value) else { return }
|
||||
wrapper.headerReceivedCallback?(wrapper, key, value)
|
||||
})
|
||||
} else {
|
||||
httpAPI.setHeaderReceivedCallback.unsafelyUnwrapped(pointer, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when all headers have been read.
|
||||
public func setHeadersReadCallback(_ callback: ((HTTPConnection) -> Void)?) {
|
||||
headersReadCallback = callback
|
||||
if callback != nil {
|
||||
httpAPI.setHeadersReadCallback.unsafelyUnwrapped(pointer, { connection in
|
||||
guard let wrapper = HTTPConnection.wrapper(for: connection) else { return }
|
||||
wrapper.headersReadCallback?(wrapper)
|
||||
})
|
||||
} else {
|
||||
httpAPI.setHeadersReadCallback.unsafelyUnwrapped(pointer, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when response data is available to read.
|
||||
public func setResponseCallback(_ callback: ((HTTPConnection) -> Void)?) {
|
||||
responseCallback = callback
|
||||
if callback != nil {
|
||||
httpAPI.setResponseCallback.unsafelyUnwrapped(pointer, { connection in
|
||||
guard let wrapper = HTTPConnection.wrapper(for: connection) else { return }
|
||||
wrapper.responseCallback?(wrapper)
|
||||
})
|
||||
} else {
|
||||
httpAPI.setResponseCallback.unsafelyUnwrapped(pointer, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the request finishes.
|
||||
public func setRequestCompleteCallback(_ callback: ((HTTPConnection) -> Void)?) {
|
||||
requestCompleteCallback = callback
|
||||
if callback != nil {
|
||||
httpAPI.setRequestCompleteCallback.unsafelyUnwrapped(pointer, { connection in
|
||||
guard let wrapper = HTTPConnection.wrapper(for: connection) else { return }
|
||||
wrapper.requestCompleteCallback?(wrapper)
|
||||
})
|
||||
} else {
|
||||
httpAPI.setRequestCompleteCallback.unsafelyUnwrapped(pointer, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the connection closes.
|
||||
public func setConnectionClosedCallback(_ callback: ((HTTPConnection) -> Void)?) {
|
||||
connectionClosedCallback = callback
|
||||
if callback != nil {
|
||||
httpAPI.setConnectionClosedCallback.unsafelyUnwrapped(pointer, { connection in
|
||||
guard let wrapper = HTTPConnection.wrapper(for: connection) else { return }
|
||||
wrapper.connectionClosedCallback?(wrapper)
|
||||
})
|
||||
} else {
|
||||
httpAPI.setConnectionClosedCallback.unsafelyUnwrapped(pointer, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TCP
|
||||
|
||||
/// A TCP connection to a server. Wraps `TCPConnection`.
|
||||
public final class TCPConnection {
|
||||
let pointer: OpaquePointer
|
||||
|
||||
var openCompletion: ((TCPConnection, NetError?) -> Void)?
|
||||
var connectionClosedCallback: ((TCPConnection, NetError?) -> Void)?
|
||||
|
||||
/// Requests permission to connect to `server`. If the reply is
|
||||
/// `.ask`, the completion is called later with the user's answer.
|
||||
@discardableResult
|
||||
public static func requestAccess(server: String, port: Int, useSSL: Bool = true,
|
||||
purpose: String? = nil,
|
||||
completion: @escaping (Bool) -> Void) -> AccessReply {
|
||||
Playdate.Network.requestAccess(
|
||||
rawRequest: { tcpAPI.requestAccess.unsafelyUnwrapped($0, $1, $2, $3, $4, $5) },
|
||||
server: server, port: port, useSSL: useSSL, purpose: purpose,
|
||||
completion: completion)
|
||||
}
|
||||
|
||||
/// Creates a connection to `server`. Fails if access has not been
|
||||
/// granted. Call `open(_:)` to connect.
|
||||
public init?(server: String, port: Int, useSSL: Bool = true) {
|
||||
let pointer = server.withPlaydateCString {
|
||||
tcpAPI.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL)
|
||||
}
|
||||
guard let pointer else { return nil }
|
||||
self.pointer = pointer
|
||||
tcpAPI.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque())
|
||||
}
|
||||
|
||||
deinit {
|
||||
tcpAPI.setUserdata.unsafelyUnwrapped(pointer, nil)
|
||||
tcpAPI.release.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
private static func wrapper(for pointer: OpaquePointer?) -> TCPConnection? {
|
||||
guard let pointer,
|
||||
let userdata = tcpAPI.getUserdata.unsafelyUnwrapped(pointer) else { return nil }
|
||||
return Unmanaged<TCPConnection>.fromOpaque(userdata).takeUnretainedValue()
|
||||
}
|
||||
|
||||
/// The last error on the connection, if any.
|
||||
public var error: NetError? {
|
||||
Playdate.Network.optionalError(tcpAPI.getError.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The time to wait for the connection to open, in milliseconds.
|
||||
public func setConnectTimeout(milliseconds: Int) {
|
||||
tcpAPI.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
|
||||
}
|
||||
|
||||
/// Opens the connection. The completion receives `nil` on success.
|
||||
public func open(_ completion: @escaping (TCPConnection, NetError?) -> Void) throws(NetError) {
|
||||
openCompletion = completion
|
||||
let error = tcpAPI.open.unsafelyUnwrapped(pointer, { connection, error, _ in
|
||||
guard let wrapper = TCPConnection.wrapper(for: connection) else { return }
|
||||
let completion = wrapper.openCompletion
|
||||
wrapper.openCompletion = nil
|
||||
completion?(wrapper, Playdate.Network.optionalError(error))
|
||||
}, nil)
|
||||
try Playdate.Network.check(error)
|
||||
}
|
||||
|
||||
/// Closes the connection.
|
||||
public func close() throws(NetError) {
|
||||
try Playdate.Network.check(tcpAPI.close.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// Called when the connection closes, with the reason if it closed
|
||||
/// due to an error.
|
||||
public func setConnectionClosedCallback(_ callback: ((TCPConnection, NetError?) -> Void)?) {
|
||||
connectionClosedCallback = callback
|
||||
if callback != nil {
|
||||
tcpAPI.setConnectionClosedCallback.unsafelyUnwrapped(pointer, { connection, error in
|
||||
guard let wrapper = TCPConnection.wrapper(for: connection) else { return }
|
||||
wrapper.connectionClosedCallback?(wrapper, Playdate.Network.optionalError(error))
|
||||
})
|
||||
} else {
|
||||
tcpAPI.setConnectionClosedCallback.unsafelyUnwrapped(pointer, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// The time to wait for incoming data, in milliseconds.
|
||||
public func setReadTimeout(milliseconds: Int) {
|
||||
tcpAPI.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
|
||||
}
|
||||
|
||||
/// The size of the connection's read buffer, in bytes.
|
||||
public func setReadBufferSize(bytes: Int) {
|
||||
tcpAPI.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes))
|
||||
}
|
||||
|
||||
/// The number of bytes available to read.
|
||||
public var bytesAvailable: Int {
|
||||
Int(tcpAPI.getBytesAvailable.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The number of written bytes not yet sent on the wire.
|
||||
public var sentBytesPending: Int {
|
||||
Int(tcpAPI.getSentBytesPending.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// Reads up to `buffer.count` bytes, waiting up to the read timeout.
|
||||
/// Returns the number of bytes read.
|
||||
public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int {
|
||||
let result = tcpAPI.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
|
||||
if result < 0 {
|
||||
throw NetError(rawValue: result) ?? .unknown
|
||||
}
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
/// Reads up to `length` bytes, waiting up to the read timeout.
|
||||
public func read(length: Int) throws(NetError) -> [UInt8] {
|
||||
var bytes = [UInt8](repeating: 0, count: length)
|
||||
let result = bytes.withUnsafeMutableBytes { buffer in
|
||||
tcpAPI.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
|
||||
}
|
||||
if result < 0 {
|
||||
throw NetError(rawValue: result) ?? .unknown
|
||||
}
|
||||
bytes.removeLast(length - Int(result))
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// Writes the buffer to the connection. Returns the number of bytes
|
||||
/// accepted.
|
||||
@discardableResult
|
||||
public func write(_ buffer: UnsafeRawBufferPointer) throws(NetError) -> Int {
|
||||
let result = tcpAPI.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
|
||||
if result < 0 {
|
||||
throw NetError(rawValue: result) ?? .unknown
|
||||
}
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
/// Writes the bytes to the connection. Returns the number of bytes
|
||||
/// accepted.
|
||||
@discardableResult
|
||||
public func write(_ bytes: [UInt8]) throws(NetError) -> Int {
|
||||
let result = bytes.withUnsafeBytes { buffer in
|
||||
tcpAPI.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
|
||||
}
|
||||
if result < 0 {
|
||||
throw NetError(rawValue: result) ?? .unknown
|
||||
}
|
||||
return Int(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// PlayDate.swift
|
||||
// Swift bindings to the Playdate C API.
|
||||
//
|
||||
// The C API is delivered as a `PlaydateAPI` struct of function pointers that
|
||||
// the firmware hands to the game's `eventHandler` entry point. Call
|
||||
// `Playdate.initialize(with:)` from that entry point before using any other
|
||||
// API in this module.
|
||||
//
|
||||
|
||||
public import CPlaydate
|
||||
|
||||
public enum Playdate {
|
||||
/// The raw C API. Populated by `initialize(with:)`.
|
||||
///
|
||||
/// Access is unsynchronized: the Playdate runtime is single-threaded and
|
||||
/// the API pointer is written exactly once at startup.
|
||||
public internal(set) nonisolated(unsafe) static var api: PlaydateAPI!
|
||||
|
||||
/// The raw C API pointer handed to `initialize(with:)`, for calls that
|
||||
/// need to pass the `PlaydateAPI*` back to C.
|
||||
public internal(set) nonisolated(unsafe) static var apiPointer: UnsafeMutablePointer<PlaydateAPI>!
|
||||
|
||||
/// Stores the API pointer handed to the game's `eventHandler`.
|
||||
///
|
||||
/// Call this first, on the `.initialize` event, before using any other
|
||||
/// wrapper in this module.
|
||||
public static func initialize(with pointer: UnsafeMutableRawPointer) {
|
||||
apiPointer = pointer.assumingMemoryBound(to: PlaydateAPI.self)
|
||||
api = apiPointer.pointee
|
||||
}
|
||||
|
||||
/// An error reported by the Playdate OS.
|
||||
public struct Error: Swift.Error, Sendable {
|
||||
public let message: String
|
||||
|
||||
init(message: String) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
init(cString: UnsafePointer<CChar>?) {
|
||||
self.init(message: String(playdateCString: cString) ?? "unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's answer to a permission request (microphone, network).
|
||||
public enum AccessReply: UInt32, Sendable {
|
||||
case ask = 0
|
||||
case deny = 1
|
||||
case allow = 2
|
||||
}
|
||||
|
||||
/// A Swift view of `PDSystemEvent` with the key code folded into the
|
||||
/// key events.
|
||||
public enum SystemEvent {
|
||||
case initialize
|
||||
case initializeLua
|
||||
case lock
|
||||
case unlock
|
||||
case pause
|
||||
case resume
|
||||
case terminate
|
||||
case keyPressed(keyCode: UInt32)
|
||||
case keyReleased(keyCode: UInt32)
|
||||
case lowPower
|
||||
case mirrorStarted
|
||||
case mirrorEnded
|
||||
|
||||
public init?(event: PDSystemEvent, argument: UInt32) {
|
||||
switch event {
|
||||
case kEventInit: self = .initialize
|
||||
case kEventInitLua: self = .initializeLua
|
||||
case kEventLock: self = .lock
|
||||
case kEventUnlock: self = .unlock
|
||||
case kEventPause: self = .pause
|
||||
case kEventResume: self = .resume
|
||||
case kEventTerminate: self = .terminate
|
||||
case kEventKeyPressed: self = .keyPressed(keyCode: argument)
|
||||
case kEventKeyReleased: self = .keyReleased(keyCode: argument)
|
||||
case kEventLowPower: self = .lowPower
|
||||
case kEventMirrorStarted: self = .mirrorStarted
|
||||
case kEventMirrorEnded: self = .mirrorEnded
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//
|
||||
// Scoreboards.swift
|
||||
// Wraps `playdate->scoreboards` (pd_api_scoreboards.h).
|
||||
//
|
||||
// The C callbacks carry no userdata, so one completion per operation kind
|
||||
// is tracked at a time; starting a second request of the same kind before
|
||||
// the first completes replaces the stored completion.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
private var scoreboardsAPI: playdate_scoreboards { Playdate.api.scoreboards.pointee }
|
||||
|
||||
extension Playdate {
|
||||
/// The scoreboards API for games with online leaderboards.
|
||||
public enum Scoreboards {}
|
||||
}
|
||||
|
||||
extension Playdate.Scoreboards {
|
||||
/// A score on a board.
|
||||
public struct Score {
|
||||
public let rank: UInt32
|
||||
public let value: UInt32
|
||||
public let player: String
|
||||
public let boardID: String?
|
||||
|
||||
init(_ score: PDScore) {
|
||||
rank = score.rank
|
||||
value = score.value
|
||||
player = String(playdateCString: score.player) ?? ""
|
||||
boardID = String(playdateCString: score.boardID)
|
||||
}
|
||||
|
||||
init(_ score: PDListScore, boardID: String?) {
|
||||
rank = score.rank
|
||||
value = score.value
|
||||
player = String(playdateCString: score.player) ?? ""
|
||||
self.boardID = boardID
|
||||
}
|
||||
}
|
||||
|
||||
/// The scores on a board.
|
||||
public struct ScoresList {
|
||||
public let boardID: String
|
||||
public let lastUpdated: UInt32
|
||||
public let playerIncluded: Bool
|
||||
public let limit: UInt32
|
||||
public let scores: [Score]
|
||||
|
||||
init(_ list: PDScoresList) {
|
||||
boardID = String(playdateCString: list.boardID) ?? ""
|
||||
lastUpdated = list.lastUpdated
|
||||
playerIncluded = list.playerIncluded != 0
|
||||
limit = list.limit
|
||||
var scores = [Score]()
|
||||
if let entries = list.scores {
|
||||
scores.reserveCapacity(Int(list.count))
|
||||
for index in 0..<Int(list.count) {
|
||||
scores.append(Score(entries[index], boardID: boardID))
|
||||
}
|
||||
}
|
||||
self.scores = scores
|
||||
}
|
||||
}
|
||||
|
||||
/// A board belonging to the game.
|
||||
public struct Board {
|
||||
public let boardID: String
|
||||
public let name: String
|
||||
|
||||
init(_ board: PDBoard) {
|
||||
boardID = String(playdateCString: board.boardID) ?? ""
|
||||
name = String(playdateCString: board.name) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's boards.
|
||||
public struct BoardsList {
|
||||
public let lastUpdated: UInt32
|
||||
public let boards: [Board]
|
||||
|
||||
init(_ list: PDBoardsList) {
|
||||
lastUpdated = list.lastUpdated
|
||||
var boards = [Board]()
|
||||
if let entries = list.boards {
|
||||
boards.reserveCapacity(Int(list.count))
|
||||
for index in 0..<Int(list.count) {
|
||||
boards.append(Board(entries[index]))
|
||||
}
|
||||
}
|
||||
self.boards = boards
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var addScoreCompletion: ((Result<Score, Playdate.Error>) -> Void)?
|
||||
nonisolated(unsafe) private static var personalBestCompletion: ((Result<Score, Playdate.Error>) -> Void)?
|
||||
nonisolated(unsafe) private static var boardsCompletion: ((Result<BoardsList, Playdate.Error>) -> Void)?
|
||||
nonisolated(unsafe) private static var scoresCompletion: ((Result<ScoresList, Playdate.Error>) -> Void)?
|
||||
|
||||
/// Submits a score to the board. Returns `false` if the request could
|
||||
/// not be started.
|
||||
@discardableResult
|
||||
public static func addScore(boardID: String, value: UInt32,
|
||||
completion: @escaping (Result<Score, Playdate.Error>) -> Void) -> Bool {
|
||||
addScoreCompletion = completion
|
||||
return boardID.withPlaydateCString { cBoardID in
|
||||
scoreboardsAPI.addScore.unsafelyUnwrapped(cBoardID, value, { score, errorMessage in
|
||||
let completion = Playdate.Scoreboards.addScoreCompletion
|
||||
Playdate.Scoreboards.addScoreCompletion = nil
|
||||
completion?(Playdate.Scoreboards.result(score, errorMessage))
|
||||
}) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the current player's best score on the board.
|
||||
@discardableResult
|
||||
public static func getPersonalBest(boardID: String,
|
||||
completion: @escaping (Result<Score, Playdate.Error>) -> Void) -> Bool {
|
||||
personalBestCompletion = completion
|
||||
return boardID.withPlaydateCString { cBoardID in
|
||||
scoreboardsAPI.getPersonalBest.unsafelyUnwrapped(cBoardID, { score, errorMessage in
|
||||
let completion = Playdate.Scoreboards.personalBestCompletion
|
||||
Playdate.Scoreboards.personalBestCompletion = nil
|
||||
completion?(Playdate.Scoreboards.result(score, errorMessage))
|
||||
}) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the list of the game's boards.
|
||||
@discardableResult
|
||||
public static func getScoreboards(completion: @escaping (Result<BoardsList, Playdate.Error>) -> Void) -> Bool {
|
||||
boardsCompletion = completion
|
||||
return scoreboardsAPI.getScoreboards.unsafelyUnwrapped({ boards, errorMessage in
|
||||
let completion = Playdate.Scoreboards.boardsCompletion
|
||||
Playdate.Scoreboards.boardsCompletion = nil
|
||||
guard let boards else {
|
||||
completion?(.failure(Playdate.Error(cString: errorMessage)))
|
||||
return
|
||||
}
|
||||
let list = BoardsList(boards.pointee)
|
||||
scoreboardsAPI.freeBoardsList.unsafelyUnwrapped(boards)
|
||||
completion?(.success(list))
|
||||
}) != 0
|
||||
}
|
||||
|
||||
/// Fetches the scores on the board.
|
||||
@discardableResult
|
||||
public static func getScores(boardID: String,
|
||||
completion: @escaping (Result<ScoresList, Playdate.Error>) -> Void) -> Bool {
|
||||
scoresCompletion = completion
|
||||
return boardID.withPlaydateCString { cBoardID in
|
||||
scoreboardsAPI.getScores.unsafelyUnwrapped(cBoardID, { scores, errorMessage in
|
||||
let completion = Playdate.Scoreboards.scoresCompletion
|
||||
Playdate.Scoreboards.scoresCompletion = nil
|
||||
guard let scores else {
|
||||
completion?(.failure(Playdate.Error(cString: errorMessage)))
|
||||
return
|
||||
}
|
||||
let list = ScoresList(scores.pointee)
|
||||
scoreboardsAPI.freeScoresList.unsafelyUnwrapped(scores)
|
||||
completion?(.success(list))
|
||||
}) != 0
|
||||
}
|
||||
}
|
||||
|
||||
private static func result(_ score: UnsafeMutablePointer<PDScore>?,
|
||||
_ errorMessage: UnsafePointer<CChar>?) -> Result<Score, Playdate.Error> {
|
||||
guard let score else {
|
||||
return .failure(Playdate.Error(cString: errorMessage))
|
||||
}
|
||||
let value = Score(score.pointee)
|
||||
scoreboardsAPI.freeScore.unsafelyUnwrapped(score)
|
||||
return .success(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//
|
||||
// Sound.swift
|
||||
// Wraps `playdate->sound` (pd_api_sound.h): the namespace, top-level audio
|
||||
// functions, and SoundChannel. Sources, signals, synths, and effects live in
|
||||
// their own files.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
var snd: playdate_sound { Playdate.api.sound.pointee }
|
||||
|
||||
extension Playdate {
|
||||
/// The sound API: channels, players, synths, sequences, and effects.
|
||||
public enum Sound {}
|
||||
}
|
||||
|
||||
extension Playdate.Sound {
|
||||
/// A note as a MIDI note number, where 60 is middle C. Fractional values
|
||||
/// are valid.
|
||||
public typealias MIDINote = Float
|
||||
|
||||
/// Middle C (`NOTE_C4`).
|
||||
public static let noteC4: MIDINote = 60
|
||||
|
||||
/// The number of audio frames rendered per system audio cycle
|
||||
/// (`AUDIO_FRAMES_PER_CYCLE`).
|
||||
public static let audioFramesPerCycle = 512
|
||||
|
||||
/// Converts a MIDI note to a frequency in Hz.
|
||||
public static func frequency(forNote note: MIDINote) -> Float {
|
||||
pd_noteToFrequency(note)
|
||||
}
|
||||
|
||||
/// Converts a frequency in Hz to a MIDI note.
|
||||
public static func note(forFrequency frequency: Float) -> MIDINote {
|
||||
pd_frequencyToNote(frequency)
|
||||
}
|
||||
|
||||
/// The format of sample data.
|
||||
public enum Format: UInt32, Sendable {
|
||||
case mono8bit = 0
|
||||
case stereo8bit = 1
|
||||
case mono16bit = 2
|
||||
case stereo16bit = 3
|
||||
case monoADPCM = 4
|
||||
case stereoADPCM = 5
|
||||
|
||||
init(_ format: SoundFormat) { self = Format(rawValue: format.rawValue) ?? .mono16bit }
|
||||
var cValue: SoundFormat { SoundFormat(rawValue) }
|
||||
|
||||
public var isStereo: Bool { rawValue & 1 != 0 }
|
||||
public var is16bit: Bool { rawValue >= 2 && rawValue < 4 }
|
||||
public var bytesPerFrame: Int { Int(SoundFormat_bytesPerFrame(cValue)) }
|
||||
}
|
||||
|
||||
/// The microphone used when recording.
|
||||
public enum MicSource: UInt32, Sendable {
|
||||
case autodetect = 0
|
||||
case internalMic = 1
|
||||
case headset = 2
|
||||
}
|
||||
|
||||
/// The user's answer to a permission request.
|
||||
public typealias AccessReply = Playdate.AccessReply
|
||||
|
||||
/// The most recent sound error as a thrown error.
|
||||
static func lastError() -> Playdate.Error {
|
||||
Playdate.Error(cString: snd.getError.unsafelyUnwrapped())
|
||||
}
|
||||
|
||||
// MARK: - Top-level functions
|
||||
|
||||
/// The audio engine's current time, in frames (44,100 per second).
|
||||
public static var currentTime: UInt32 {
|
||||
snd.getCurrentTime.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
/// The most recent audio error message, if any.
|
||||
public static var error: String? {
|
||||
String(playdateCString: snd.getError.unsafelyUnwrapped())
|
||||
}
|
||||
|
||||
/// Removes a source from its channel.
|
||||
@discardableResult
|
||||
public static func removeSource(_ source: Source) -> Bool {
|
||||
snd.removeSource.unsafelyUnwrapped(source.pointer) != 0
|
||||
}
|
||||
|
||||
/// Sets a callback that records microphone input. Return `false` from the
|
||||
/// callback to stop recording. Pass `nil` to stop recording immediately.
|
||||
/// The buffer contains mono 16-bit samples.
|
||||
@discardableResult
|
||||
public static func setMicCallback(source: MicSource = .autodetect,
|
||||
_ callback: ((UnsafeMutableBufferPointer<Int16>) -> Bool)?) -> Bool {
|
||||
micCallback = callback
|
||||
if callback != nil {
|
||||
return snd.setMicCallback.unsafelyUnwrapped({ _, buffer, length in
|
||||
let samples = UnsafeMutableBufferPointer(start: buffer, count: Int(length))
|
||||
return Playdate.Sound.micCallback?(samples) == true ? 1 : 0
|
||||
}, nil, CPlaydate.MicSource(source.rawValue)) != 0
|
||||
} else {
|
||||
return snd.setMicCallback.unsafelyUnwrapped(nil, nil, CPlaydate.MicSource(source.rawValue)) != 0
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var micCallback: ((UnsafeMutableBufferPointer<Int16>) -> Bool)?
|
||||
|
||||
/// Asks the user for permission to record from the microphone. `purpose`
|
||||
/// is shown in the permission prompt. The completion receives whether
|
||||
/// access was granted; it is not called if the reply was already
|
||||
/// determined (the returned value is `.deny` or `.allow`).
|
||||
@discardableResult
|
||||
public static func requestMicAccess(purpose: String? = nil,
|
||||
_ completion: @escaping (Bool) -> Void) -> AccessReply {
|
||||
final class Box { let body: (Bool) -> Void; init(_ body: @escaping (Bool) -> Void) { self.body = body } }
|
||||
let box = Unmanaged.passRetained(Box(completion))
|
||||
let trampoline: @convention(c) (Bool, UnsafeMutableRawPointer?) -> Void = { allowed, userdata in
|
||||
guard let userdata else { return }
|
||||
let box = Unmanaged<Box>.fromOpaque(userdata).takeRetainedValue()
|
||||
box.body(allowed)
|
||||
}
|
||||
let reply: accessReply
|
||||
if let purpose {
|
||||
reply = purpose.withPlaydateCString {
|
||||
snd.requestMicAccess.unsafelyUnwrapped($0, trampoline, box.toOpaque())
|
||||
}
|
||||
} else {
|
||||
reply = snd.requestMicAccess.unsafelyUnwrapped(nil, trampoline, box.toOpaque())
|
||||
}
|
||||
if reply != kAccessAsk {
|
||||
// The callback will not be invoked; balance the retain.
|
||||
box.release()
|
||||
}
|
||||
return AccessReply(rawValue: reply.rawValue) ?? .ask
|
||||
}
|
||||
|
||||
/// The current headphone and headset-microphone state.
|
||||
public static var headphoneState: (headphone: Bool, headsetMic: Bool) {
|
||||
var headphone: Int32 = 0, headsetMic: Int32 = 0
|
||||
snd.getHeadphoneState.unsafelyUnwrapped(&headphone, &headsetMic, nil)
|
||||
return (headphone != 0, headsetMic != 0)
|
||||
}
|
||||
|
||||
/// Installs a callback invoked when the headphone or headset-mic state
|
||||
/// changes.
|
||||
public static func setHeadphoneChangeCallback(_ callback: ((_ headphone: Bool, _ headsetMic: Bool) -> Void)?) {
|
||||
headphoneChangeCallback = callback
|
||||
if callback != nil {
|
||||
snd.getHeadphoneState.unsafelyUnwrapped(nil, nil, { headphone, mic in
|
||||
Playdate.Sound.headphoneChangeCallback?(headphone != 0, mic != 0)
|
||||
})
|
||||
} else {
|
||||
snd.getHeadphoneState.unsafelyUnwrapped(nil, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var headphoneChangeCallback: ((Bool, Bool) -> Void)?
|
||||
|
||||
/// Forces audio output to the headphone and/or speaker. When the
|
||||
/// headphone jack drives output and `speaker` is also set, the speaker
|
||||
/// plays too.
|
||||
public static func setOutputsActive(headphone: Bool, speaker: Bool) {
|
||||
snd.setOutputsActive.unsafelyUnwrapped(headphone ? 1 : 0, speaker ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Adds a callback-based source to the default channel. The callback
|
||||
/// fills the sample buffers and returns `true` if it produced output.
|
||||
/// Buffers hold 16-bit samples; `right` is non-nil only when `stereo`.
|
||||
public static func addSource(stereo: Bool,
|
||||
_ callback: @escaping CallbackSource.Callback) -> CallbackSource {
|
||||
let source = CallbackSource(callback: callback)
|
||||
let pointer = snd.addSource.unsafelyUnwrapped(
|
||||
CallbackSource.trampoline, source.contextPointer, stereo ? 1 : 0)
|
||||
source.adopt(pointer: pointer.unsafelyUnwrapped)
|
||||
return source
|
||||
}
|
||||
|
||||
// MARK: - Channels
|
||||
|
||||
/// A mixer channel holding sources and effects. Wraps `SoundChannel`.
|
||||
public final class Channel {
|
||||
private static var api: playdate_sound_channel { snd.channel.pointee }
|
||||
|
||||
let pointer: OpaquePointer
|
||||
let isOwned: Bool
|
||||
private var retainedSources: [Source] = []
|
||||
private var retainedEffects: [Effect] = []
|
||||
private var retainedModulators: [SignalValue] = []
|
||||
|
||||
init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
/// Creates a new channel. Add it to the sound engine with `add()`.
|
||||
public convenience init() {
|
||||
self.init(pointer: Channel.api.newChannel.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
Channel.api.freeChannel.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The default channel, which sources are added to unless otherwise
|
||||
/// specified.
|
||||
public static var `default`: Channel {
|
||||
Channel(pointer: snd.getDefaultChannel.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: false)
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var addedChannels: [Channel] = []
|
||||
|
||||
/// Adds the channel to the sound engine.
|
||||
@discardableResult
|
||||
public func add() -> Bool {
|
||||
let added = snd.addChannel.unsafelyUnwrapped(pointer) != 0
|
||||
if added, !Channel.addedChannels.contains(where: { $0 === self }) {
|
||||
Channel.addedChannels.append(self)
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
/// Removes the channel from the sound engine.
|
||||
@discardableResult
|
||||
public func remove() -> Bool {
|
||||
let removed = snd.removeChannel.unsafelyUnwrapped(pointer) != 0
|
||||
Channel.addedChannels.removeAll { $0 === self }
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Adds a source to the channel. A source can only be on one channel.
|
||||
@discardableResult
|
||||
public func addSource(_ source: Source) -> Bool {
|
||||
let added = Channel.api.addSource.unsafelyUnwrapped(pointer, source.pointer) != 0
|
||||
if added, !retainedSources.contains(where: { $0 === source }) {
|
||||
retainedSources.append(source)
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func removeSource(_ source: Source) -> Bool {
|
||||
let removed = Channel.api.removeSource.unsafelyUnwrapped(pointer, source.pointer) != 0
|
||||
retainedSources.removeAll { $0 === source }
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Adds a callback-based source to the channel. The callback fills
|
||||
/// the sample buffers and returns `true` if it produced output.
|
||||
public func addCallbackSource(stereo: Bool,
|
||||
_ callback: @escaping CallbackSource.Callback) -> CallbackSource {
|
||||
let source = CallbackSource(callback: callback)
|
||||
let pointer = Channel.api.addCallbackSource.unsafelyUnwrapped(
|
||||
self.pointer, CallbackSource.trampoline, source.contextPointer, stereo ? 1 : 0)
|
||||
source.adopt(pointer: pointer.unsafelyUnwrapped)
|
||||
retainedSources.append(source)
|
||||
return source
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func addEffect(_ effect: Effect) -> Bool {
|
||||
let added = Channel.api.addEffect.unsafelyUnwrapped(pointer, effect.pointer) != 0
|
||||
if added, !retainedEffects.contains(where: { $0 === effect }) {
|
||||
retainedEffects.append(effect)
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func removeEffect(_ effect: Effect) -> Bool {
|
||||
let removed = Channel.api.removeEffect.unsafelyUnwrapped(pointer, effect.pointer) != 0
|
||||
retainedEffects.removeAll { $0 === effect }
|
||||
return removed
|
||||
}
|
||||
|
||||
/// The channel's volume, 0...1.
|
||||
public var volume: Float {
|
||||
get { Channel.api.getVolume.unsafelyUnwrapped(pointer) }
|
||||
set { Channel.api.setVolume.unsafelyUnwrapped(pointer, newValue) }
|
||||
}
|
||||
|
||||
/// Modulates the channel's volume.
|
||||
public func setVolumeModulator(_ modulator: SignalValue?) {
|
||||
retain(modulator)
|
||||
Channel.api.setVolumeModulator.unsafelyUnwrapped(pointer, modulator?.pointer)
|
||||
}
|
||||
|
||||
public var volumeModulator: SignalValue? {
|
||||
SignalValue.wrap(Channel.api.getVolumeModulator.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The channel's stereo pan: -1 (left) to 1 (right).
|
||||
public func setPan(_ pan: Float) {
|
||||
Channel.api.setPan.unsafelyUnwrapped(pointer, pan)
|
||||
}
|
||||
|
||||
/// Modulates the channel's pan. The signal's range 0...1 maps to
|
||||
/// left...right.
|
||||
public func setPanModulator(_ modulator: SignalValue?) {
|
||||
retain(modulator)
|
||||
Channel.api.setPanModulator.unsafelyUnwrapped(pointer, modulator?.pointer)
|
||||
}
|
||||
|
||||
public var panModulator: SignalValue? {
|
||||
SignalValue.wrap(Channel.api.getPanModulator.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// A signal following the channel's dry (unprocessed) level.
|
||||
public var dryLevelSignal: SignalValue? {
|
||||
SignalValue.wrap(Channel.api.getDryLevelSignal.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// A signal following the channel's wet (processed) level.
|
||||
public var wetLevelSignal: SignalValue? {
|
||||
SignalValue.wrap(Channel.api.getWetLevelSignal.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The channel's output as a source, for feeding into another channel.
|
||||
public var outputAsSource: Source? {
|
||||
guard let source = Channel.api.getOutputAsSource.unsafelyUnwrapped(pointer) else {
|
||||
return nil
|
||||
}
|
||||
return Source(pointer: source, isOwned: false)
|
||||
}
|
||||
|
||||
private func retain(_ modulator: SignalValue?) {
|
||||
if let modulator, !retainedModulators.contains(where: { $0 === modulator }) {
|
||||
retainedModulators.append(modulator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//
|
||||
// SoundEffect.swift
|
||||
// SoundEffect wrappers: filters, bitcrusher, ring modulator, delay line,
|
||||
// and overdrive.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
private var effectAPI: playdate_sound_effect { snd.effect.pointee }
|
||||
|
||||
extension Playdate.Sound {
|
||||
/// An effect that processes a channel's audio: the base class of the
|
||||
/// built-in effects. Wraps `SoundEffect`.
|
||||
public class Effect {
|
||||
/// Processes up to `AUDIO_FRAMES_PER_CYCLE` sample frames in signed
|
||||
/// Q8.24 format. `bufferActive` is `false` when the input buffer is
|
||||
/// silent. Returns `true` if the effect produced output.
|
||||
public typealias Processor = (_ left: UnsafeMutableBufferPointer<Int32>,
|
||||
_ right: UnsafeMutableBufferPointer<Int32>?,
|
||||
_ bufferActive: Bool) -> Bool
|
||||
|
||||
let pointer: OpaquePointer
|
||||
let isOwned: Bool
|
||||
private var retainedMixModulator: SignalValue?
|
||||
private var processorBox: Unmanaged<ProcessorBox>?
|
||||
|
||||
final class ProcessorBox {
|
||||
let processor: Processor
|
||||
init(_ processor: @escaping Processor) { self.processor = processor }
|
||||
}
|
||||
|
||||
init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
/// Creates an effect that processes audio with a Swift callback.
|
||||
public init(processor: @escaping Processor) {
|
||||
let box = Unmanaged.passRetained(ProcessorBox(processor))
|
||||
processorBox = box
|
||||
pointer = effectAPI.newEffect.unsafelyUnwrapped({ effect, left, right, nsamples, bufactive in
|
||||
guard let effect, let left,
|
||||
let userdata = effectAPI.getUserdata.unsafelyUnwrapped(effect) else { return 0 }
|
||||
let box = Unmanaged<ProcessorBox>.fromOpaque(userdata).takeUnretainedValue()
|
||||
let leftBuffer = UnsafeMutableBufferPointer(start: left, count: Int(nsamples))
|
||||
let rightBuffer = right.map { UnsafeMutableBufferPointer(start: $0, count: Int(nsamples)) }
|
||||
return box.processor(leftBuffer, rightBuffer, bufactive != 0) ? 1 : 0
|
||||
}, box.toOpaque()).unsafelyUnwrapped
|
||||
isOwned = true
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
effectAPI.freeEffect.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
processorBox?.release()
|
||||
}
|
||||
|
||||
/// The wet/dry mix: 1 is fully processed, 0 fully dry.
|
||||
public func setMix(_ level: Float) {
|
||||
effectAPI.setMix.unsafelyUnwrapped(pointer, level)
|
||||
}
|
||||
|
||||
public var mixModulator: SignalValue? {
|
||||
get { SignalValue.wrap(effectAPI.getMixModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retainedMixModulator = newValue
|
||||
effectAPI.setMixModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Two-pole filter
|
||||
|
||||
/// A two-pole IIR filter. Wraps `TwoPoleFilter`.
|
||||
public final class TwoPoleFilter: Effect {
|
||||
private static var api: playdate_sound_effect_twopolefilter { effectAPI.twopolefilter.pointee }
|
||||
|
||||
public enum Kind: UInt32, Sendable {
|
||||
case lowPass = 0
|
||||
case highPass = 1
|
||||
case bandPass = 2
|
||||
case notch = 3
|
||||
case peq = 4
|
||||
case lowShelf = 5
|
||||
case highShelf = 6
|
||||
|
||||
var cValue: TwoPoleFilterType { TwoPoleFilterType(rawValue) }
|
||||
}
|
||||
|
||||
private var retainedFrequencyModulator: SignalValue?
|
||||
private var retainedResonanceModulator: SignalValue?
|
||||
|
||||
public init(kind: Kind = .lowPass) {
|
||||
super.init(pointer: TwoPoleFilter.api.newFilter.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
setKind(kind)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
TwoPoleFilter.api.freeFilter.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
public func setKind(_ kind: Kind) {
|
||||
TwoPoleFilter.api.setType.unsafelyUnwrapped(pointer, kind.cValue)
|
||||
}
|
||||
|
||||
/// The center/corner frequency, in Hz.
|
||||
public func setFrequency(_ frequency: Float) {
|
||||
TwoPoleFilter.api.setFrequency.unsafelyUnwrapped(pointer, frequency)
|
||||
}
|
||||
|
||||
public var frequencyModulator: SignalValue? {
|
||||
get { SignalValue.wrap(TwoPoleFilter.api.getFrequencyModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retainedFrequencyModulator = newValue
|
||||
TwoPoleFilter.api.setFrequencyModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The gain, used by PEQ and shelf filters.
|
||||
public func setGain(_ gain: Float) {
|
||||
TwoPoleFilter.api.setGain.unsafelyUnwrapped(pointer, gain)
|
||||
}
|
||||
|
||||
public func setResonance(_ resonance: Float) {
|
||||
TwoPoleFilter.api.setResonance.unsafelyUnwrapped(pointer, resonance)
|
||||
}
|
||||
|
||||
public var resonanceModulator: SignalValue? {
|
||||
get { SignalValue.wrap(TwoPoleFilter.api.getResonanceModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retainedResonanceModulator = newValue
|
||||
TwoPoleFilter.api.setResonanceModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One-pole filter
|
||||
|
||||
/// A one-pole low/high-pass filter. Wraps `OnePoleFilter`.
|
||||
public final class OnePoleFilter: Effect {
|
||||
private static var api: playdate_sound_effect_onepolefilter { effectAPI.onepolefilter.pointee }
|
||||
|
||||
private var retainedParameterModulator: SignalValue?
|
||||
|
||||
public init() {
|
||||
super.init(pointer: OnePoleFilter.api.newFilter.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
OnePoleFilter.api.freeFilter.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The filter's cutoff: -1 to 1, where values above 0 are low-pass
|
||||
/// and values below 0 high-pass.
|
||||
public func setParameter(_ parameter: Float) {
|
||||
OnePoleFilter.api.setParameter.unsafelyUnwrapped(pointer, parameter)
|
||||
}
|
||||
|
||||
public var parameterModulator: SignalValue? {
|
||||
get { SignalValue.wrap(OnePoleFilter.api.getParameterModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retainedParameterModulator = newValue
|
||||
OnePoleFilter.api.setParameterModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bit crusher
|
||||
|
||||
/// A bit-crushing and downsampling effect. Wraps `BitCrusher`.
|
||||
public final class BitCrusher: Effect {
|
||||
private static var api: playdate_sound_effect_bitcrusher { effectAPI.bitcrusher.pointee }
|
||||
|
||||
private var retainedModulators: [SignalValue] = []
|
||||
|
||||
public init() {
|
||||
super.init(pointer: BitCrusher.api.newBitCrusher.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
BitCrusher.api.freeBitCrusher.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// When `true`, `setDepth` values map exponentially to bit depth.
|
||||
public func setExponential(_ flag: Bool) {
|
||||
BitCrusher.api.setExponential.unsafelyUnwrapped(pointer, flag)
|
||||
}
|
||||
|
||||
/// The amount of crushing, 0 (none) to 1 (quantized to 1 bit).
|
||||
public func setDepth(_ depth: Float) {
|
||||
BitCrusher.api.setDepth.unsafelyUnwrapped(pointer, depth)
|
||||
}
|
||||
|
||||
public var depthModulator: SignalValue? {
|
||||
get { SignalValue.wrap(BitCrusher.api.getDepthModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retain(newValue)
|
||||
BitCrusher.api.setDepthModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The amount of downsampling, 0 (none) to 1 (every sample repeated).
|
||||
public func setDownsampling(_ downsampling: Float) {
|
||||
BitCrusher.api.setDownsampling.unsafelyUnwrapped(pointer, downsampling)
|
||||
}
|
||||
|
||||
public var downsamplingModulator: SignalValue? {
|
||||
get { SignalValue.wrap(BitCrusher.api.getDownsamplingModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retain(newValue)
|
||||
BitCrusher.api.setDownsamplingModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
private func retain(_ modulator: SignalValue?) {
|
||||
if let modulator { retainedModulators.append(modulator) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ring modulator
|
||||
|
||||
/// A ring modulator effect. Wraps `RingModulator`.
|
||||
public final class RingModulator: Effect {
|
||||
private static var api: playdate_sound_effect_ringmodulator { effectAPI.ringmodulator.pointee }
|
||||
|
||||
private var retainedFrequencyModulator: SignalValue?
|
||||
|
||||
public init() {
|
||||
super.init(pointer: RingModulator.api.newRingmod.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
RingModulator.api.freeRingmod.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The modulation frequency, in Hz.
|
||||
public func setFrequency(_ frequency: Float) {
|
||||
RingModulator.api.setFrequency.unsafelyUnwrapped(pointer, frequency)
|
||||
}
|
||||
|
||||
public var frequencyModulator: SignalValue? {
|
||||
get { SignalValue.wrap(RingModulator.api.getFrequencyModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retainedFrequencyModulator = newValue
|
||||
RingModulator.api.setFrequencyModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delay line
|
||||
|
||||
/// A tap into a delay line; produces audio and can be added to a channel
|
||||
/// as a source. Wraps `DelayLineTap`.
|
||||
public final class DelayLineTap: Source {
|
||||
private static var api: playdate_sound_effect_delayline { effectAPI.delayline.pointee }
|
||||
|
||||
/// The delay line is retained so the tap stays valid.
|
||||
private let delayLine: DelayLine
|
||||
private var retainedDelayModulator: SignalValue?
|
||||
|
||||
init(pointer: OpaquePointer, delayLine: DelayLine) {
|
||||
self.delayLine = delayLine
|
||||
super.init(pointer: pointer, isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
DelayLineTap.api.freeTap.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// The tap's position in the delay line, in frames.
|
||||
public func setDelay(frames: Int) {
|
||||
DelayLineTap.api.setTapDelay.unsafelyUnwrapped(pointer, Int32(frames))
|
||||
}
|
||||
|
||||
public var delayModulator: SignalValue? {
|
||||
get { SignalValue.wrap(DelayLineTap.api.getTapDelayModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retainedDelayModulator = newValue
|
||||
DelayLineTap.api.setTapDelayModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// For stereo delay lines: swaps the left and right channels.
|
||||
public func setChannelsFlipped(_ flipped: Bool) {
|
||||
DelayLineTap.api.setTapChannelsFlipped.unsafelyUnwrapped(pointer, flipped ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A delay line effect. Wraps `DelayLine`.
|
||||
public final class DelayLine: Effect {
|
||||
private static var api: playdate_sound_effect_delayline { effectAPI.delayline.pointee }
|
||||
|
||||
/// Creates a delay line holding `length` frames.
|
||||
public init(length: Int, stereo: Bool = false) {
|
||||
super.init(pointer: DelayLine.api.newDelayLine.unsafelyUnwrapped(
|
||||
Int32(length), stereo ? 1 : 0).unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
DelayLine.api.freeDelayLine.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes the delay length. Cannot be larger than the line's
|
||||
/// original length.
|
||||
public func setLength(frames: Int) {
|
||||
DelayLine.api.setLength.unsafelyUnwrapped(pointer, Int32(frames))
|
||||
}
|
||||
|
||||
/// The feedback level, 0...1.
|
||||
public func setFeedback(_ feedback: Float) {
|
||||
DelayLine.api.setFeedback.unsafelyUnwrapped(pointer, feedback)
|
||||
}
|
||||
|
||||
/// Adds a tap `delay` frames behind the write head. The tap can be
|
||||
/// added to a channel as a sound source.
|
||||
public func addTap(delay: Int) -> DelayLineTap? {
|
||||
guard let tap = DelayLine.api.addTap.unsafelyUnwrapped(pointer, Int32(delay)) else {
|
||||
return nil
|
||||
}
|
||||
return DelayLineTap(pointer: tap, delayLine: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overdrive
|
||||
|
||||
/// An overdrive/distortion effect. Wraps `Overdrive`.
|
||||
public final class Overdrive: Effect {
|
||||
private static var api: playdate_sound_effect_overdrive { effectAPI.overdrive.pointee }
|
||||
|
||||
private var retainedModulators: [SignalValue] = []
|
||||
|
||||
public init() {
|
||||
super.init(pointer: Overdrive.api.newOverdrive.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
Overdrive.api.freeOverdrive.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The input gain applied before clipping.
|
||||
public func setGain(_ gain: Float) {
|
||||
Overdrive.api.setGain.unsafelyUnwrapped(pointer, gain)
|
||||
}
|
||||
|
||||
/// The level where the amplified input clips.
|
||||
public func setLimit(_ limit: Float) {
|
||||
Overdrive.api.setLimit.unsafelyUnwrapped(pointer, limit)
|
||||
}
|
||||
|
||||
public var limitModulator: SignalValue? {
|
||||
get { SignalValue.wrap(Overdrive.api.getLimitModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retain(newValue)
|
||||
Overdrive.api.setLimitModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// A DC offset applied to the input, making the clipping asymmetric.
|
||||
public func setOffset(_ offset: Float) {
|
||||
Overdrive.api.setOffset.unsafelyUnwrapped(pointer, offset)
|
||||
}
|
||||
|
||||
public var offsetModulator: SignalValue? {
|
||||
get { SignalValue.wrap(Overdrive.api.getOffsetModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retain(newValue)
|
||||
Overdrive.api.setOffsetModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
private func retain(_ modulator: SignalValue?) {
|
||||
if let modulator { retainedModulators.append(modulator) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//
|
||||
// SoundSignal.swift
|
||||
// Signal wrappers: PDSynthSignalValue, PDSynthSignal, PDSynthLFO,
|
||||
// PDSynthEnvelope, and ControlSignal.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
extension Playdate.Sound {
|
||||
/// A value that can modulate a parameter. The base class of `Signal`,
|
||||
/// `LFO`, `Envelope`, and `ControlSignal`. Wraps `PDSynthSignalValue`.
|
||||
public class SignalValue {
|
||||
let pointer: OpaquePointer
|
||||
let isOwned: Bool
|
||||
|
||||
init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
/// Wraps a signal value pointer returned by the OS (not owned).
|
||||
static func wrap(_ pointer: OpaquePointer?) -> SignalValue? {
|
||||
guard let pointer else { return nil }
|
||||
return SignalValue(pointer: pointer, isOwned: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// A signal object; also provides custom signals driven by Swift
|
||||
/// callbacks. Wraps `PDSynthSignal`.
|
||||
public final class Signal: SignalValue {
|
||||
private static var api: playdate_sound_signal { snd.signal.pointee }
|
||||
|
||||
/// Custom signal callbacks.
|
||||
public struct Callbacks {
|
||||
/// Returns the signal's value at the end of the current cycle.
|
||||
/// `ioFrames` is the number of frames until the cycle ends and
|
||||
/// may be lowered to interpolate toward `interpolationValue`.
|
||||
public var step: (_ ioFrames: UnsafeMutablePointer<Int32>?,
|
||||
_ interpolationValue: UnsafeMutablePointer<Float>?) -> Float
|
||||
/// Called on note-on events. `length` is -1 for indefinite notes.
|
||||
public var noteOn: ((_ note: MIDINote, _ velocity: Float, _ length: Float) -> Void)?
|
||||
/// Called on note-off events. `stopped` is `false` when the note
|
||||
/// is released and `true` when it actually stops playing;
|
||||
/// `offset` is the frame offset within the current cycle.
|
||||
public var noteOff: ((_ stopped: Bool, _ offset: Int) -> Void)?
|
||||
|
||||
public init(step: @escaping (_ ioFrames: UnsafeMutablePointer<Int32>?,
|
||||
_ interpolationValue: UnsafeMutablePointer<Float>?) -> Float,
|
||||
noteOn: ((_ note: MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil,
|
||||
noteOff: ((_ stopped: Bool, _ offset: Int) -> Void)? = nil) {
|
||||
self.step = step
|
||||
self.noteOn = noteOn
|
||||
self.noteOff = noteOff
|
||||
}
|
||||
}
|
||||
|
||||
private final class Box {
|
||||
let callbacks: Callbacks
|
||||
init(_ callbacks: Callbacks) { self.callbacks = callbacks }
|
||||
}
|
||||
|
||||
/// Creates a signal driven by the given callbacks.
|
||||
public init(callbacks: Callbacks) {
|
||||
let box = Unmanaged.passRetained(Box(callbacks))
|
||||
let pointer = Signal.api.newSignal.unsafelyUnwrapped(
|
||||
{ userdata, ioFrames, interpolationValue in
|
||||
guard let userdata else { return 0 }
|
||||
let box = Unmanaged<Box>.fromOpaque(userdata).takeUnretainedValue()
|
||||
return box.callbacks.step(ioFrames, interpolationValue)
|
||||
},
|
||||
{ userdata, note, velocity, length in
|
||||
guard let userdata else { return }
|
||||
let box = Unmanaged<Box>.fromOpaque(userdata).takeUnretainedValue()
|
||||
box.callbacks.noteOn?(note, velocity, length)
|
||||
},
|
||||
{ userdata, stopped, offset in
|
||||
guard let userdata else { return }
|
||||
let box = Unmanaged<Box>.fromOpaque(userdata).takeUnretainedValue()
|
||||
box.callbacks.noteOff?(stopped != 0, Int(offset))
|
||||
},
|
||||
{ userdata in
|
||||
guard let userdata else { return }
|
||||
Unmanaged<Box>.fromOpaque(userdata).release()
|
||||
},
|
||||
box.toOpaque())
|
||||
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
/// Creates a plain signal object wrapping an existing signal value,
|
||||
/// so it can be scaled and offset.
|
||||
public init(value: SignalValue) {
|
||||
let pointer = Signal.api.newSignalForValue.unsafelyUnwrapped(value.pointer)
|
||||
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
override init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
super.init(pointer: pointer, isOwned: isOwned)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
Signal.api.freeSignal.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The signal's current value.
|
||||
public var value: Float {
|
||||
Signal.api.getValue.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Scales the signal's output.
|
||||
public func setValueScale(_ scale: Float) {
|
||||
Signal.api.setValueScale.unsafelyUnwrapped(pointer, scale)
|
||||
}
|
||||
|
||||
/// Offsets the signal's output.
|
||||
public func setValueOffset(_ offset: Float) {
|
||||
Signal.api.setValueOffset.unsafelyUnwrapped(pointer, offset)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LFO
|
||||
|
||||
/// A low-frequency oscillator signal. Wraps `PDSynthLFO`.
|
||||
public final class LFO: SignalValue {
|
||||
private static var api: playdate_sound_lfo { snd.lfo.pointee }
|
||||
|
||||
/// The oscillator's waveform.
|
||||
public enum Shape: UInt32, Sendable {
|
||||
case square = 0
|
||||
case triangle = 1
|
||||
case sine = 2
|
||||
case sampleAndHold = 3
|
||||
case sawtoothUp = 4
|
||||
case sawtoothDown = 5
|
||||
case arpeggiator = 6
|
||||
case function = 7
|
||||
|
||||
var cValue: LFOType { LFOType(rawValue) }
|
||||
}
|
||||
|
||||
var function: ((LFO) -> Float)?
|
||||
|
||||
public init(shape: Shape = .sine) {
|
||||
let pointer = LFO.api.newLFO.unsafelyUnwrapped(shape.cValue)
|
||||
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
LFO.api.freeLFO.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
public func setShape(_ shape: Shape) {
|
||||
LFO.api.setType.unsafelyUnwrapped(pointer, shape.cValue)
|
||||
}
|
||||
|
||||
/// The LFO rate, in cycles per second.
|
||||
public func setRate(_ rate: Float) {
|
||||
LFO.api.setRate.unsafelyUnwrapped(pointer, rate)
|
||||
}
|
||||
|
||||
/// The current phase, 0...1.
|
||||
public func setPhase(_ phase: Float) {
|
||||
LFO.api.setPhase.unsafelyUnwrapped(pointer, phase)
|
||||
}
|
||||
|
||||
/// The phase the LFO starts at when a note starts, 0...1.
|
||||
public func setStartPhase(_ phase: Float) {
|
||||
LFO.api.setStartPhase.unsafelyUnwrapped(pointer, phase)
|
||||
}
|
||||
|
||||
/// The center value of the LFO output.
|
||||
public func setCenter(_ center: Float) {
|
||||
LFO.api.setCenter.unsafelyUnwrapped(pointer, center)
|
||||
}
|
||||
|
||||
/// The amplitude of the LFO around its center.
|
||||
public func setDepth(_ depth: Float) {
|
||||
LFO.api.setDepth.unsafelyUnwrapped(pointer, depth)
|
||||
}
|
||||
|
||||
/// For `.arpeggiator` LFOs: the sequence of values (in half-steps)
|
||||
/// to step through.
|
||||
public func setArpeggiation(_ steps: [Float]) {
|
||||
var steps = steps
|
||||
steps.withUnsafeMutableBufferPointer { buffer in
|
||||
LFO.api.setArpeggiation.unsafelyUnwrapped(pointer, Int32(buffer.count),
|
||||
buffer.baseAddress)
|
||||
}
|
||||
}
|
||||
|
||||
/// For `.function` LFOs: the Swift function providing the value. If
|
||||
/// `interpolate` is `true`, values are interpolated between calls.
|
||||
public func setFunction(interpolate: Bool = false, _ function: @escaping (LFO) -> Float) {
|
||||
self.function = function
|
||||
LFO.api.setFunction.unsafelyUnwrapped(pointer, { _, userdata in
|
||||
guard let userdata else { return 0 }
|
||||
let lfo = Unmanaged<LFO>.fromOpaque(userdata).takeUnretainedValue()
|
||||
return lfo.function?(lfo) ?? 0
|
||||
}, Unmanaged.passUnretained(self).toOpaque(), interpolate ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Waits `holdoff` seconds after a note starts, then ramps the LFO
|
||||
/// depth up over `rampTime` seconds.
|
||||
public func setDelay(holdoff: Float, rampTime: Float) {
|
||||
LFO.api.setDelay.unsafelyUnwrapped(pointer, holdoff, rampTime)
|
||||
}
|
||||
|
||||
/// Whether the LFO phase restarts on every new note.
|
||||
public func setRetrigger(_ flag: Bool) {
|
||||
LFO.api.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0)
|
||||
}
|
||||
|
||||
/// When `true`, the LFO runs globally instead of per-note.
|
||||
public func setGlobal(_ global: Bool) {
|
||||
LFO.api.setGlobal.unsafelyUnwrapped(pointer, global ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Seeds the random number generator used by `.sampleAndHold` LFOs.
|
||||
public func setRandomSeed(_ seed: UInt16) {
|
||||
LFO.api.setRandomSeed.unsafelyUnwrapped(pointer, seed)
|
||||
}
|
||||
|
||||
public var value: Float {
|
||||
LFO.api.getValue.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Envelope
|
||||
|
||||
/// An ADSR envelope signal. Wraps `PDSynthEnvelope`.
|
||||
public final class Envelope: SignalValue {
|
||||
private static var api: playdate_sound_envelope { snd.envelope.pointee }
|
||||
|
||||
/// Creates an envelope with the given attack and decay times
|
||||
/// (seconds), sustain level (0...1), and release time (seconds).
|
||||
public init(attack: Float = 0, decay: Float = 0, sustain: Float = 1, release: Float = 0) {
|
||||
let pointer = Envelope.api.newEnvelope.unsafelyUnwrapped(attack, decay, sustain, release)
|
||||
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
override init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
super.init(pointer: pointer, isOwned: isOwned)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
Envelope.api.freeEnvelope.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
public func setAttack(_ attack: Float) {
|
||||
Envelope.api.setAttack.unsafelyUnwrapped(pointer, attack)
|
||||
}
|
||||
|
||||
public func setDecay(_ decay: Float) {
|
||||
Envelope.api.setDecay.unsafelyUnwrapped(pointer, decay)
|
||||
}
|
||||
|
||||
public func setSustain(_ sustain: Float) {
|
||||
Envelope.api.setSustain.unsafelyUnwrapped(pointer, sustain)
|
||||
}
|
||||
|
||||
public func setRelease(_ release: Float) {
|
||||
Envelope.api.setRelease.unsafelyUnwrapped(pointer, release)
|
||||
}
|
||||
|
||||
/// When `true`, a new note while a note is playing does not restart
|
||||
/// the envelope.
|
||||
public func setLegato(_ flag: Bool) {
|
||||
Envelope.api.setLegato.unsafelyUnwrapped(pointer, flag ? 1 : 0)
|
||||
}
|
||||
|
||||
/// When `true`, a new note restarts the envelope from zero instead of
|
||||
/// its current value.
|
||||
public func setRetrigger(_ flag: Bool) {
|
||||
Envelope.api.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Bends the envelope's segments: 0 is linear, 1 is maximum curvature.
|
||||
public func setCurvature(_ amount: Float) {
|
||||
Envelope.api.setCurvature.unsafelyUnwrapped(pointer, amount)
|
||||
}
|
||||
|
||||
/// How much note velocity scales the envelope's output.
|
||||
public func setVelocitySensitivity(_ sensitivity: Float) {
|
||||
Envelope.api.setVelocitySensitivity.unsafelyUnwrapped(pointer, sensitivity)
|
||||
}
|
||||
|
||||
/// Scales the envelope's rate by note: notes above `start` play the
|
||||
/// envelope faster (up to `scaling` at `end` and beyond).
|
||||
public func setRateScaling(_ scaling: Float, start: MIDINote, end: MIDINote) {
|
||||
Envelope.api.setRateScaling.unsafelyUnwrapped(pointer, scaling, start, end)
|
||||
}
|
||||
|
||||
public var value: Float {
|
||||
Envelope.api.getValue.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ControlSignal
|
||||
|
||||
/// A signal whose values are set on a sequence timeline. Wraps
|
||||
/// `ControlSignal`.
|
||||
public final class ControlSignal: SignalValue {
|
||||
private static var api: playdate_control_signal { snd.controlsignal.pointee }
|
||||
|
||||
public init() {
|
||||
let pointer = ControlSignal.api.newSignal.unsafelyUnwrapped()
|
||||
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
override init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
super.init(pointer: pointer, isOwned: isOwned)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
ControlSignal.api.freeSignal.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
public func clearEvents() {
|
||||
ControlSignal.api.clearEvents.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Adds a value at `step` in the signal's timeline. If `interpolate`
|
||||
/// is `true`, the value ramps from the previous event.
|
||||
public func addEvent(step: Int, value: Float, interpolate: Bool = false) {
|
||||
ControlSignal.api.addEvent.unsafelyUnwrapped(pointer, Int32(step), value,
|
||||
interpolate ? 1 : 0)
|
||||
}
|
||||
|
||||
public func removeEvent(step: Int) {
|
||||
ControlSignal.api.removeEvent.unsafelyUnwrapped(pointer, Int32(step))
|
||||
}
|
||||
|
||||
/// The MIDI controller number for signals loaded from a MIDI file.
|
||||
public var midiControllerNumber: Int {
|
||||
Int(ControlSignal.api.getMIDIControllerNumber.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//
|
||||
// SoundSource.swift
|
||||
// SoundSource, FilePlayer, AudioSample, and SamplePlayer wrappers.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
extension Playdate.Sound {
|
||||
/// A source of audio: the base class of `FilePlayer`, `SamplePlayer`,
|
||||
/// `Synth`, `DelayLineTap`, and `CallbackSource`. Wraps `SoundSource`.
|
||||
public class Source {
|
||||
private static var api: playdate_sound_source { snd.source.pointee }
|
||||
|
||||
/// The underlying C object. Set once, immediately after creation.
|
||||
var pointer: OpaquePointer!
|
||||
let isOwned: Bool
|
||||
var finishCallback: ((Source) -> Void)?
|
||||
|
||||
init(pointer: OpaquePointer?, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
/// Sets the playback volume for the left and right channels, 0...1.
|
||||
public func setVolume(left: Float, right: Float) {
|
||||
Source.api.setVolume.unsafelyUnwrapped(pointer, left, right)
|
||||
}
|
||||
|
||||
/// Sets the playback volume of both channels.
|
||||
public func setVolume(_ volume: Float) {
|
||||
setVolume(left: volume, right: volume)
|
||||
}
|
||||
|
||||
/// The playback volume of the left and right channels.
|
||||
public var volume: (left: Float, right: Float) {
|
||||
var left: Float = 0, right: Float = 0
|
||||
Source.api.getVolume.unsafelyUnwrapped(pointer, &left, &right)
|
||||
return (left, right)
|
||||
}
|
||||
|
||||
public var isPlaying: Bool {
|
||||
Source.api.isPlaying.unsafelyUnwrapped(pointer) != 0
|
||||
}
|
||||
|
||||
/// Sets a function called when the source finishes playing.
|
||||
public func setFinishCallback(_ callback: ((Source) -> Void)?) {
|
||||
finishCallback = callback
|
||||
if callback != nil {
|
||||
Source.api.setFinishCallback.unsafelyUnwrapped(pointer, { _, userdata in
|
||||
guard let userdata else { return }
|
||||
let source = Unmanaged<Source>.fromOpaque(userdata).takeUnretainedValue()
|
||||
source.finishCallback?(source)
|
||||
}, Unmanaged.passUnretained(self).toOpaque())
|
||||
} else {
|
||||
Source.api.setFinishCallback.unsafelyUnwrapped(pointer, nil, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A source that produces audio by calling back into Swift.
|
||||
public final class CallbackSource: Source {
|
||||
/// Fills the sample buffers and returns `true` if output was
|
||||
/// produced. `right` is non-nil only for stereo sources.
|
||||
public typealias Callback = (_ left: UnsafeMutableBufferPointer<Int16>,
|
||||
_ right: UnsafeMutableBufferPointer<Int16>?) -> Bool
|
||||
|
||||
let callback: Callback
|
||||
|
||||
/// Sources created through the top-level `Sound.addSource` are kept
|
||||
/// alive here until removed with `Sound.removeSource`.
|
||||
nonisolated(unsafe) static var live: [CallbackSource] = []
|
||||
|
||||
init(callback: @escaping Callback) {
|
||||
self.callback = callback
|
||||
super.init(pointer: nil, isOwned: false)
|
||||
}
|
||||
|
||||
var contextPointer: UnsafeMutableRawPointer {
|
||||
Unmanaged.passUnretained(self).toOpaque()
|
||||
}
|
||||
|
||||
static let trampoline: @convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer<Int16>?,
|
||||
UnsafeMutablePointer<Int16>?, Int32) -> Int32 = { context, left, right, length in
|
||||
guard let context, let left else { return 0 }
|
||||
let source = Unmanaged<CallbackSource>.fromOpaque(context).takeUnretainedValue()
|
||||
let leftBuffer = UnsafeMutableBufferPointer(start: left, count: Int(length))
|
||||
let rightBuffer = right.map { UnsafeMutableBufferPointer(start: $0, count: Int(length)) }
|
||||
return source.callback(leftBuffer, rightBuffer) ? 1 : 0
|
||||
}
|
||||
|
||||
/// Attaches the C object created for this source.
|
||||
func adopt(pointer: OpaquePointer) {
|
||||
self.pointer = pointer
|
||||
CallbackSource.live.append(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FilePlayer
|
||||
|
||||
/// Streams audio from a file. Wraps `FilePlayer`.
|
||||
public final class FilePlayer: Source {
|
||||
private static var api: playdate_sound_fileplayer { snd.fileplayer.pointee }
|
||||
|
||||
var loopCallback: ((FilePlayer) -> Void)?
|
||||
var fadeCallback: ((FilePlayer) -> Void)?
|
||||
var mp3DataSource: ((UnsafeMutableBufferPointer<UInt8>) -> Int)?
|
||||
private var retainedRateModulator: SignalValue?
|
||||
|
||||
override init(pointer: OpaquePointer?, isOwned: Bool) {
|
||||
super.init(pointer: pointer, isOwned: isOwned)
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(pointer: FilePlayer.api.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
/// Creates a player and loads the audio file at `path`.
|
||||
public convenience init(path: String) throws(Playdate.Error) {
|
||||
self.init()
|
||||
try load(path: path)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
FilePlayer.api.freePlayer.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepares the player to stream the file at `path`.
|
||||
public func load(path: String) throws(Playdate.Error) {
|
||||
let loaded = path.withPlaydateCString {
|
||||
FilePlayer.api.loadIntoPlayer.unsafelyUnwrapped(pointer, $0) != 0
|
||||
}
|
||||
if !loaded {
|
||||
throw Playdate.Error(message: "unable to load audio file: \(path)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the length of the stream buffer, in seconds. Default 0.25.
|
||||
public func setBufferLength(_ seconds: Float) {
|
||||
FilePlayer.api.setBufferLength.unsafelyUnwrapped(pointer, seconds)
|
||||
}
|
||||
|
||||
/// Starts playback, looping `repeat` times; 0 loops endlessly.
|
||||
@discardableResult
|
||||
public func play(repeat repeatCount: Int = 1) -> Bool {
|
||||
FilePlayer.api.play.unsafelyUnwrapped(pointer, Int32(repeatCount)) != 0
|
||||
}
|
||||
|
||||
public func pause() {
|
||||
FilePlayer.api.pause.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
FilePlayer.api.stop.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// The file's length in seconds.
|
||||
public var length: Float {
|
||||
FilePlayer.api.getLength.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// The playback position in seconds.
|
||||
public var offset: Float {
|
||||
get { FilePlayer.api.getOffset.unsafelyUnwrapped(pointer) }
|
||||
set { FilePlayer.api.setOffset.unsafelyUnwrapped(pointer, newValue) }
|
||||
}
|
||||
|
||||
/// The playback rate; 1 is normal speed, negative values are not
|
||||
/// supported.
|
||||
public var rate: Float {
|
||||
get { FilePlayer.api.getRate.unsafelyUnwrapped(pointer) }
|
||||
set { FilePlayer.api.setRate.unsafelyUnwrapped(pointer, newValue) }
|
||||
}
|
||||
|
||||
/// Loops playback between `start` and `end` (seconds) while playing
|
||||
/// with `repeat` 0. An `end` of 0 means the end of the file.
|
||||
public func setLoopRange(start: Float, end: Float) {
|
||||
FilePlayer.api.setLoopRange.unsafelyUnwrapped(pointer, start, end)
|
||||
}
|
||||
|
||||
/// Whether playback underran because the file could not be read fast
|
||||
/// enough.
|
||||
public var didUnderrun: Bool {
|
||||
FilePlayer.api.didUnderrun.unsafelyUnwrapped(pointer) != 0
|
||||
}
|
||||
|
||||
/// Stops playback (instead of looping the buffer) on underrun.
|
||||
public func setStopOnUnderrun(_ flag: Bool) {
|
||||
FilePlayer.api.setStopOnUnderrun.unsafelyUnwrapped(pointer, flag ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Sets a function called every time playback loops.
|
||||
public func setLoopCallback(_ callback: ((FilePlayer) -> Void)?) {
|
||||
loopCallback = callback
|
||||
if callback != nil {
|
||||
FilePlayer.api.setLoopCallback.unsafelyUnwrapped(pointer, { _, userdata in
|
||||
guard let userdata else { return }
|
||||
let player = Unmanaged<FilePlayer>.fromOpaque(userdata).takeUnretainedValue()
|
||||
player.loopCallback?(player)
|
||||
}, Unmanaged.passUnretained(self).toOpaque())
|
||||
} else {
|
||||
FilePlayer.api.setLoopCallback.unsafelyUnwrapped(pointer, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fades the volume to the given levels over `length` sample frames,
|
||||
/// then calls `completion`.
|
||||
public func fadeVolume(left: Float, right: Float, length: Int32,
|
||||
completion: ((FilePlayer) -> Void)? = nil) {
|
||||
fadeCallback = completion
|
||||
if completion != nil {
|
||||
FilePlayer.api.fadeVolume.unsafelyUnwrapped(pointer, left, right, length, { _, userdata in
|
||||
guard let userdata else { return }
|
||||
let player = Unmanaged<FilePlayer>.fromOpaque(userdata).takeUnretainedValue()
|
||||
player.fadeCallback?(player)
|
||||
}, Unmanaged.passUnretained(self).toOpaque())
|
||||
} else {
|
||||
FilePlayer.api.fadeVolume.unsafelyUnwrapped(pointer, left, right, length, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams MP3 data from a callback instead of a file. The callback
|
||||
/// fills the buffer and returns the number of bytes written; return 0
|
||||
/// to signal the end of the stream.
|
||||
public func setMP3StreamSource(bufferLength: Float,
|
||||
_ dataSource: @escaping (UnsafeMutableBufferPointer<UInt8>) -> Int) {
|
||||
mp3DataSource = dataSource
|
||||
FilePlayer.api.setMP3StreamSource.unsafelyUnwrapped(pointer, { data, bytes, userdata in
|
||||
guard let userdata, let data else { return 0 }
|
||||
let player = Unmanaged<FilePlayer>.fromOpaque(userdata).takeUnretainedValue()
|
||||
let buffer = UnsafeMutableBufferPointer(start: data, count: Int(bytes))
|
||||
return Int32(player.mp3DataSource?(buffer) ?? 0)
|
||||
}, Unmanaged.passUnretained(self).toOpaque(), bufferLength)
|
||||
}
|
||||
|
||||
/// Modulates the playback rate.
|
||||
public var rateModulator: SignalValue? {
|
||||
get { SignalValue.wrap(FilePlayer.api.getRateModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retainedRateModulator = newValue
|
||||
FilePlayer.api.setRateModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AudioSample
|
||||
|
||||
/// Audio data loaded into memory. Wraps `AudioSample`.
|
||||
public final class AudioSample {
|
||||
private static var api: playdate_sound_sample { snd.sample.pointee }
|
||||
|
||||
let pointer: OpaquePointer
|
||||
let isOwned: Bool
|
||||
|
||||
init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
/// Allocates a sample buffer with room for `byteCount` bytes.
|
||||
public convenience init(byteCount: Int) {
|
||||
self.init(pointer: AudioSample.api.newSampleBuffer.unsafelyUnwrapped(
|
||||
Int32(byteCount)).unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
/// Loads the wav or aiff file at `path`.
|
||||
public convenience init(path: String) throws(Playdate.Error) {
|
||||
let pointer = path.withPlaydateCString { AudioSample.api.load.unsafelyUnwrapped($0) }
|
||||
guard let pointer else {
|
||||
throw Playdate.Error(message: "unable to load sample: \(path)")
|
||||
}
|
||||
self.init(pointer: pointer, isOwned: true)
|
||||
}
|
||||
|
||||
/// Creates a sample referencing existing sample data. If
|
||||
/// `freeWhenDone` is `true`, the OS frees `data` when the sample is
|
||||
/// freed; otherwise the caller must keep `data` valid for the
|
||||
/// sample's lifetime.
|
||||
public convenience init?(data: UnsafeMutablePointer<UInt8>, format: Format,
|
||||
sampleRate: UInt32, byteCount: Int, freeWhenDone: Bool) {
|
||||
guard let pointer = AudioSample.api.newSampleFromData.unsafelyUnwrapped(
|
||||
data, format.cValue, sampleRate, Int32(byteCount), freeWhenDone ? 1 : 0) else {
|
||||
return nil
|
||||
}
|
||||
self.init(pointer: pointer, isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
AudioSample.api.freeSample.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the file at `path` into this sample's buffer.
|
||||
public func load(path: String) throws(Playdate.Error) {
|
||||
let loaded = path.withPlaydateCString {
|
||||
AudioSample.api.loadIntoSample.unsafelyUnwrapped(pointer, $0) != 0
|
||||
}
|
||||
if !loaded {
|
||||
throw Playdate.Error(message: "unable to load sample: \(path)")
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample's raw data, format, and rate.
|
||||
public var data: (data: UnsafeMutablePointer<UInt8>?, format: Format,
|
||||
sampleRate: UInt32, byteLength: UInt32) {
|
||||
var data: UnsafeMutablePointer<UInt8>?
|
||||
var format = kSound16bitMono
|
||||
var sampleRate: UInt32 = 0, byteLength: UInt32 = 0
|
||||
AudioSample.api.getData.unsafelyUnwrapped(pointer, &data, &format, &sampleRate, &byteLength)
|
||||
return (data, Format(format), sampleRate, byteLength)
|
||||
}
|
||||
|
||||
/// The sample's length in seconds.
|
||||
public var length: Float {
|
||||
AudioSample.api.getLength.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Decompresses an ADPCM sample to 16-bit PCM so it can be used in a
|
||||
/// synth. Returns `false` if there is not enough memory.
|
||||
@discardableResult
|
||||
public func decompress() -> Bool {
|
||||
AudioSample.api.decompress.unsafelyUnwrapped(pointer) != 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SamplePlayer
|
||||
|
||||
/// Plays an `AudioSample` from memory. Wraps `SamplePlayer`.
|
||||
public final class SamplePlayer: Source {
|
||||
private static var api: playdate_sound_sampleplayer { snd.sampleplayer.pointee }
|
||||
|
||||
var loopCallback: ((SamplePlayer) -> Void)?
|
||||
private var retainedSample: AudioSample?
|
||||
private var retainedRateModulator: SignalValue?
|
||||
|
||||
override init(pointer: OpaquePointer?, isOwned: Bool) {
|
||||
super.init(pointer: pointer, isOwned: isOwned)
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(pointer: SamplePlayer.api.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
/// Creates a player for the sample at `path`.
|
||||
public convenience init(path: String) throws(Playdate.Error) {
|
||||
self.init()
|
||||
sample = try AudioSample(path: path)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
SamplePlayer.api.freePlayer.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample to play.
|
||||
public var sample: AudioSample? {
|
||||
get { retainedSample }
|
||||
set {
|
||||
retainedSample = newValue
|
||||
SamplePlayer.api.setSample.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts playback at `rate`, looping `repeat` times; 0 loops
|
||||
/// endlessly, -1 loops ping-pong.
|
||||
@discardableResult
|
||||
public func play(repeat repeatCount: Int = 1, rate: Float = 1) -> Bool {
|
||||
SamplePlayer.api.play.unsafelyUnwrapped(pointer, Int32(repeatCount), rate) != 0
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
SamplePlayer.api.stop.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
public func setPaused(_ paused: Bool) {
|
||||
SamplePlayer.api.setPaused.unsafelyUnwrapped(pointer, paused ? 1 : 0)
|
||||
}
|
||||
|
||||
/// The sample's length in seconds.
|
||||
public var length: Float {
|
||||
SamplePlayer.api.getLength.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// The playback position in seconds.
|
||||
public var offset: Float {
|
||||
get { SamplePlayer.api.getOffset.unsafelyUnwrapped(pointer) }
|
||||
set { SamplePlayer.api.setOffset.unsafelyUnwrapped(pointer, newValue) }
|
||||
}
|
||||
|
||||
/// The playback rate; 1 is normal speed, negative plays backward.
|
||||
public var rate: Float {
|
||||
get { SamplePlayer.api.getRate.unsafelyUnwrapped(pointer) }
|
||||
set { SamplePlayer.api.setRate.unsafelyUnwrapped(pointer, newValue) }
|
||||
}
|
||||
|
||||
/// Restricts playback to the given range of sample frames.
|
||||
public func setPlayRange(start: Int, end: Int) {
|
||||
SamplePlayer.api.setPlayRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end))
|
||||
}
|
||||
|
||||
/// Sets a function called every time playback loops.
|
||||
public func setLoopCallback(_ callback: ((SamplePlayer) -> Void)?) {
|
||||
loopCallback = callback
|
||||
if callback != nil {
|
||||
SamplePlayer.api.setLoopCallback.unsafelyUnwrapped(pointer, { _, userdata in
|
||||
guard let userdata else { return }
|
||||
let player = Unmanaged<SamplePlayer>.fromOpaque(userdata).takeUnretainedValue()
|
||||
player.loopCallback?(player)
|
||||
}, Unmanaged.passUnretained(self).toOpaque())
|
||||
} else {
|
||||
SamplePlayer.api.setLoopCallback.unsafelyUnwrapped(pointer, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Modulates the playback rate.
|
||||
public var rateModulator: SignalValue? {
|
||||
get { SignalValue.wrap(SamplePlayer.api.getRateModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retainedRateModulator = newValue
|
||||
SamplePlayer.api.setRateModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//
|
||||
// SoundSynth.swift
|
||||
// Synth, Instrument, SequenceTrack, and Sequence wrappers.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
extension Playdate.Sound {
|
||||
/// A synthesizer voice. Wraps `PDSynth`.
|
||||
public final class Synth: Source {
|
||||
private static var api: playdate_sound_synth { snd.synth.pointee }
|
||||
|
||||
/// The synth's waveform.
|
||||
public enum Waveform: UInt32, Sendable {
|
||||
case square = 0
|
||||
case triangle = 1
|
||||
case sine = 2
|
||||
case noise = 3
|
||||
case sawtooth = 4
|
||||
case poPhase = 5
|
||||
case poDigital = 6
|
||||
case poVosim = 7
|
||||
|
||||
var cValue: SoundWaveform { SoundWaveform(rawValue) }
|
||||
}
|
||||
|
||||
/// Custom generator callbacks. Samples are in signed Q8.24 format.
|
||||
public struct Generator {
|
||||
/// Renders up to 256 sample frames into `left` (and `right` for
|
||||
/// stereo generators). `rate` is the per-frame phase step in
|
||||
/// Q0.32 format and `drate` its per-frame change. Returns the
|
||||
/// number of frames rendered.
|
||||
public var render: (_ left: UnsafeMutableBufferPointer<Int32>,
|
||||
_ right: UnsafeMutableBufferPointer<Int32>?,
|
||||
_ rate: UInt32, _ drate: Int32) -> Int
|
||||
/// Called when a note starts. `length` is -1 for indefinite notes.
|
||||
public var noteOn: ((_ note: MIDINote, _ velocity: Float, _ length: Float) -> Void)?
|
||||
/// Called when a note is released (`stop == false`) or stopped
|
||||
/// (`stop == true`).
|
||||
public var release: ((_ stop: Bool) -> Void)?
|
||||
/// Sets a generator parameter. Returns `true` if the parameter is
|
||||
/// valid.
|
||||
public var setParameter: ((_ parameter: Int, _ value: Float) -> Bool)?
|
||||
|
||||
public init(render: @escaping (_ left: UnsafeMutableBufferPointer<Int32>,
|
||||
_ right: UnsafeMutableBufferPointer<Int32>?,
|
||||
_ rate: UInt32, _ drate: Int32) -> Int,
|
||||
noteOn: ((_ note: MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil,
|
||||
release: ((_ stop: Bool) -> Void)? = nil,
|
||||
setParameter: ((_ parameter: Int, _ value: Float) -> Bool)? = nil) {
|
||||
self.render = render
|
||||
self.noteOn = noteOn
|
||||
self.release = release
|
||||
self.setParameter = setParameter
|
||||
}
|
||||
}
|
||||
|
||||
private final class GeneratorBox {
|
||||
let generator: Generator
|
||||
let stereo: Bool
|
||||
init(_ generator: Generator, stereo: Bool) {
|
||||
self.generator = generator
|
||||
self.stereo = stereo
|
||||
}
|
||||
}
|
||||
|
||||
private var retainedSample: AudioSample?
|
||||
private var retainedModulators: [SignalValue] = []
|
||||
|
||||
override init(pointer: OpaquePointer?, isOwned: Bool) {
|
||||
super.init(pointer: pointer, isOwned: isOwned)
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(pointer: Synth.api.newSynth.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
public convenience init(waveform: Waveform) {
|
||||
self.init()
|
||||
setWaveform(waveform)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
Synth.api.freeSynth.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the synth (and its generator, if any).
|
||||
public func copy() -> Synth {
|
||||
Synth(pointer: Synth.api.copy.unsafelyUnwrapped(pointer).unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
// MARK: Sound generation
|
||||
|
||||
public func setWaveform(_ waveform: Waveform) {
|
||||
Synth.api.setWaveform.unsafelyUnwrapped(pointer, waveform.cValue)
|
||||
}
|
||||
|
||||
/// Plays a sample instead of a waveform. A nonzero sustain range
|
||||
/// loops that part of the sample while the note is held.
|
||||
public func setSample(_ sample: AudioSample, sustainStart: UInt32 = 0, sustainEnd: UInt32 = 0) {
|
||||
retainedSample = sample
|
||||
Synth.api.setSample.unsafelyUnwrapped(pointer, sample.pointer, sustainStart, sustainEnd)
|
||||
}
|
||||
|
||||
/// Uses a wavetable for the synth. `log2size` is the base-2 log of
|
||||
/// each waveform's size (e.g. 8 for 256 samples).
|
||||
public func setWavetable(_ sample: AudioSample, log2size: Int,
|
||||
columns: Int, rows: Int) throws(Playdate.Error) {
|
||||
retainedSample = sample
|
||||
guard Synth.api.setWavetable.unsafelyUnwrapped(
|
||||
pointer, sample.pointer, Int32(log2size), Int32(columns), Int32(rows)) != 0 else {
|
||||
throw Playdate.Error(message: "invalid wavetable dimensions")
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides audio via custom Swift callbacks.
|
||||
public func setGenerator(stereo: Bool, _ generator: Generator) {
|
||||
let box = Unmanaged.passRetained(GeneratorBox(generator, stereo: stereo))
|
||||
Synth.api.setGenerator.unsafelyUnwrapped(
|
||||
pointer, stereo ? 1 : 0,
|
||||
{ userdata, left, right, nsamples, rate, drate in
|
||||
guard let userdata, let left else { return 0 }
|
||||
let box = Unmanaged<GeneratorBox>.fromOpaque(userdata).takeUnretainedValue()
|
||||
let leftBuffer = UnsafeMutableBufferPointer(start: left, count: Int(nsamples))
|
||||
let rightBuffer = right.map { UnsafeMutableBufferPointer(start: $0, count: Int(nsamples)) }
|
||||
return Int32(box.generator.render(leftBuffer, rightBuffer, rate, drate))
|
||||
},
|
||||
{ userdata, note, velocity, length in
|
||||
guard let userdata else { return }
|
||||
let box = Unmanaged<GeneratorBox>.fromOpaque(userdata).takeUnretainedValue()
|
||||
box.generator.noteOn?(note, velocity, length)
|
||||
},
|
||||
{ userdata, stop in
|
||||
guard let userdata else { return }
|
||||
let box = Unmanaged<GeneratorBox>.fromOpaque(userdata).takeUnretainedValue()
|
||||
box.generator.release?(stop != 0)
|
||||
},
|
||||
{ userdata, parameter, value in
|
||||
guard let userdata else { return 0 }
|
||||
let box = Unmanaged<GeneratorBox>.fromOpaque(userdata).takeUnretainedValue()
|
||||
return box.generator.setParameter?(Int(parameter), value) == true ? 1 : 0
|
||||
},
|
||||
{ userdata in
|
||||
guard let userdata else { return }
|
||||
Unmanaged<GeneratorBox>.fromOpaque(userdata).release()
|
||||
},
|
||||
{ userdata in
|
||||
guard let userdata else { return nil }
|
||||
let box = Unmanaged<GeneratorBox>.fromOpaque(userdata).takeUnretainedValue()
|
||||
return Unmanaged.passRetained(GeneratorBox(box.generator, stereo: box.stereo)).toOpaque()
|
||||
},
|
||||
box.toOpaque())
|
||||
}
|
||||
|
||||
// MARK: Envelope
|
||||
|
||||
public func setAttackTime(_ attack: Float) {
|
||||
Synth.api.setAttackTime.unsafelyUnwrapped(pointer, attack)
|
||||
}
|
||||
|
||||
public func setDecayTime(_ decay: Float) {
|
||||
Synth.api.setDecayTime.unsafelyUnwrapped(pointer, decay)
|
||||
}
|
||||
|
||||
public func setSustainLevel(_ sustain: Float) {
|
||||
Synth.api.setSustainLevel.unsafelyUnwrapped(pointer, sustain)
|
||||
}
|
||||
|
||||
public func setReleaseTime(_ release: Float) {
|
||||
Synth.api.setReleaseTime.unsafelyUnwrapped(pointer, release)
|
||||
}
|
||||
|
||||
/// The synth's amplitude envelope. Owned by the synth.
|
||||
public var envelope: Envelope? {
|
||||
guard let envelope = Synth.api.getEnvelope.unsafelyUnwrapped(pointer) else { return nil }
|
||||
return Envelope(pointer: envelope, isOwned: false)
|
||||
}
|
||||
|
||||
/// Clears the synth's envelope so it plays at constant volume.
|
||||
public func clearEnvelope() {
|
||||
Synth.api.clearEnvelope.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
// MARK: Modulation
|
||||
|
||||
/// Transposes played notes by `halfSteps` (fractional values allowed).
|
||||
public func setTranspose(_ halfSteps: Float) {
|
||||
Synth.api.setTranspose.unsafelyUnwrapped(pointer, halfSteps)
|
||||
}
|
||||
|
||||
public var frequencyModulator: SignalValue? {
|
||||
get { SignalValue.wrap(Synth.api.getFrequencyModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retain(newValue)
|
||||
Synth.api.setFrequencyModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
public var amplitudeModulator: SignalValue? {
|
||||
get { SignalValue.wrap(Synth.api.getAmplitudeModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retain(newValue)
|
||||
Synth.api.setAmplitudeModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of parameters the synth's generator supports.
|
||||
public var parameterCount: Int {
|
||||
Int(Synth.api.getParameterCount.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// Sets a generator parameter. Returns `false` if the parameter is
|
||||
/// invalid.
|
||||
@discardableResult
|
||||
public func setParameter(_ parameter: Int, value: Float) -> Bool {
|
||||
Synth.api.setParameter.unsafelyUnwrapped(pointer, Int32(parameter), value) != 0
|
||||
}
|
||||
|
||||
public func setParameterModulator(_ parameter: Int, _ modulator: SignalValue?) {
|
||||
retain(modulator)
|
||||
Synth.api.setParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter),
|
||||
modulator?.pointer)
|
||||
}
|
||||
|
||||
public func parameterModulator(_ parameter: Int) -> SignalValue? {
|
||||
SignalValue.wrap(Synth.api.getParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter)))
|
||||
}
|
||||
|
||||
private func retain(_ modulator: SignalValue?) {
|
||||
if let modulator, !retainedModulators.contains(where: { $0 === modulator }) {
|
||||
retainedModulators.append(modulator)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Playing
|
||||
|
||||
/// Plays a note at `frequency` Hz. `length` is in seconds; `nil`
|
||||
/// plays until `noteOff()`. `when` is the audio-clock time to start,
|
||||
/// or 0 for immediately.
|
||||
public func playNote(frequency: Float, velocity: Float = 1,
|
||||
length: Float? = nil, when: UInt32 = 0) {
|
||||
Synth.api.playNote.unsafelyUnwrapped(pointer, frequency, velocity, length ?? -1, when)
|
||||
}
|
||||
|
||||
/// Plays a MIDI note, where 60 is middle C.
|
||||
public func playMIDINote(_ note: MIDINote, velocity: Float = 1,
|
||||
length: Float? = nil, when: UInt32 = 0) {
|
||||
Synth.api.playMIDINote.unsafelyUnwrapped(pointer, note, velocity, length ?? -1, when)
|
||||
}
|
||||
|
||||
/// Releases the playing note at time `when`, or immediately if 0.
|
||||
public func noteOff(when: UInt32 = 0) {
|
||||
Synth.api.noteOff.unsafelyUnwrapped(pointer, when)
|
||||
}
|
||||
|
||||
/// Stops the synth immediately, without playing the release phase.
|
||||
public func stop() {
|
||||
Synth.api.stop.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Instrument
|
||||
|
||||
/// A bank of synth voices for playing a sequence track. Wraps
|
||||
/// `PDSynthInstrument`.
|
||||
public final class Instrument {
|
||||
private static var api: playdate_sound_instrument { snd.instrument.pointee }
|
||||
|
||||
let pointer: OpaquePointer
|
||||
let isOwned: Bool
|
||||
private var retainedVoices: [Synth] = []
|
||||
|
||||
init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(pointer: Instrument.api.newInstrument.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
Instrument.api.freeInstrument.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a voice to the instrument, handling notes in
|
||||
/// `rangeStart...rangeEnd` (0...127 handles all notes), transposed by
|
||||
/// `transpose` half-steps.
|
||||
@discardableResult
|
||||
public func addVoice(_ synth: Synth, rangeStart: MIDINote = 0, rangeEnd: MIDINote = 127,
|
||||
transpose: Float = 0) -> Bool {
|
||||
let added = Instrument.api.addVoice.unsafelyUnwrapped(
|
||||
pointer, synth.pointer, rangeStart, rangeEnd, transpose) != 0
|
||||
if added, !retainedVoices.contains(where: { $0 === synth }) {
|
||||
retainedVoices.append(synth)
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
/// Plays a note at `frequency` Hz on an available voice. Returns the
|
||||
/// synth used, if any.
|
||||
@discardableResult
|
||||
public func playNote(frequency: Float, velocity: Float = 1,
|
||||
length: Float? = nil, when: UInt32 = 0) -> Synth? {
|
||||
let synth = Instrument.api.playNote.unsafelyUnwrapped(
|
||||
pointer, frequency, velocity, length ?? -1, when)
|
||||
return voice(for: synth)
|
||||
}
|
||||
|
||||
/// Plays a MIDI note on an available voice. Returns the synth used.
|
||||
@discardableResult
|
||||
public func playMIDINote(_ note: MIDINote, velocity: Float = 1,
|
||||
length: Float? = nil, when: UInt32 = 0) -> Synth? {
|
||||
let synth = Instrument.api.playMIDINote.unsafelyUnwrapped(
|
||||
pointer, note, velocity, length ?? -1, when)
|
||||
return voice(for: synth)
|
||||
}
|
||||
|
||||
private func voice(for pointer: OpaquePointer?) -> Synth? {
|
||||
guard let pointer else { return nil }
|
||||
if let voice = retainedVoices.first(where: { $0.pointer == pointer }) {
|
||||
return voice
|
||||
}
|
||||
return Synth(pointer: pointer, isOwned: false)
|
||||
}
|
||||
|
||||
/// Bends played notes by `bend` × the pitch bend range.
|
||||
public func setPitchBend(_ bend: Float) {
|
||||
Instrument.api.setPitchBend.unsafelyUnwrapped(pointer, bend)
|
||||
}
|
||||
|
||||
public func setPitchBendRange(halfSteps: Float) {
|
||||
Instrument.api.setPitchBendRange.unsafelyUnwrapped(pointer, halfSteps)
|
||||
}
|
||||
|
||||
public func setTranspose(halfSteps: Float) {
|
||||
Instrument.api.setTranspose.unsafelyUnwrapped(pointer, halfSteps)
|
||||
}
|
||||
|
||||
/// Releases the voice playing `note` at time `when` (0 = now).
|
||||
public func noteOff(_ note: MIDINote, when: UInt32 = 0) {
|
||||
Instrument.api.noteOff.unsafelyUnwrapped(pointer, note, when)
|
||||
}
|
||||
|
||||
public func allNotesOff(when: UInt32 = 0) {
|
||||
Instrument.api.allNotesOff.unsafelyUnwrapped(pointer, when)
|
||||
}
|
||||
|
||||
public func setVolume(left: Float, right: Float) {
|
||||
Instrument.api.setVolume.unsafelyUnwrapped(pointer, left, right)
|
||||
}
|
||||
|
||||
public var volume: (left: Float, right: Float) {
|
||||
var left: Float = 0, right: Float = 0
|
||||
Instrument.api.getVolume.unsafelyUnwrapped(pointer, &left, &right)
|
||||
return (left, right)
|
||||
}
|
||||
|
||||
public var activeVoiceCount: Int {
|
||||
Int(Instrument.api.activeVoiceCount.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SequenceTrack
|
||||
|
||||
/// A track of notes played by an instrument. Wraps `SequenceTrack`.
|
||||
public final class SequenceTrack {
|
||||
private static var api: playdate_sound_track { snd.track.pointee }
|
||||
|
||||
let pointer: OpaquePointer
|
||||
let isOwned: Bool
|
||||
private var retainedInstrument: Instrument?
|
||||
|
||||
init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(pointer: SequenceTrack.api.newTrack.unsafelyUnwrapped().unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
SequenceTrack.api.freeTrack.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The instrument that plays this track's notes.
|
||||
public var instrument: Instrument? {
|
||||
get {
|
||||
if let retainedInstrument { return retainedInstrument }
|
||||
guard let instrument = SequenceTrack.api.getInstrument.unsafelyUnwrapped(pointer) else {
|
||||
return nil
|
||||
}
|
||||
return Instrument(pointer: instrument, isOwned: false)
|
||||
}
|
||||
set {
|
||||
retainedInstrument = newValue
|
||||
SequenceTrack.api.setInstrument.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a note starting at `step`, lasting `length` steps.
|
||||
public func addNote(step: UInt32, length: UInt32, note: MIDINote, velocity: Float = 1) {
|
||||
SequenceTrack.api.addNoteEvent.unsafelyUnwrapped(pointer, step, length, note, velocity)
|
||||
}
|
||||
|
||||
public func removeNote(step: UInt32, note: MIDINote) {
|
||||
SequenceTrack.api.removeNoteEvent.unsafelyUnwrapped(pointer, step, note)
|
||||
}
|
||||
|
||||
public func clearNotes() {
|
||||
SequenceTrack.api.clearNotes.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// The track's length in steps, including the tail of the last note.
|
||||
public var length: UInt32 {
|
||||
SequenceTrack.api.getLength.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// The index of the first note at or after `step`.
|
||||
public func indexForStep(_ step: UInt32) -> Int {
|
||||
Int(SequenceTrack.api.getIndexForStep.unsafelyUnwrapped(pointer, step))
|
||||
}
|
||||
|
||||
/// The note at `index`, or `nil` if the index is out of range.
|
||||
public func note(at index: Int) -> (step: UInt32, length: UInt32,
|
||||
note: MIDINote, velocity: Float)? {
|
||||
var step: UInt32 = 0, length: UInt32 = 0
|
||||
var note: MIDINote = 0
|
||||
var velocity: Float = 0
|
||||
guard SequenceTrack.api.getNoteAtIndex.unsafelyUnwrapped(
|
||||
pointer, Int32(index), &step, &length, ¬e, &velocity) != 0 else { return nil }
|
||||
return (step, length, note, velocity)
|
||||
}
|
||||
|
||||
/// The number of control signals on the track.
|
||||
public var controlSignalCount: Int {
|
||||
Int(SequenceTrack.api.getControlSignalCount.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// The control signal at `index`. Owned by the track.
|
||||
public func controlSignal(at index: Int) -> ControlSignal? {
|
||||
guard let signal = SequenceTrack.api.getControlSignal.unsafelyUnwrapped(
|
||||
pointer, Int32(index)) else { return nil }
|
||||
return ControlSignal(pointer: signal, isOwned: false)
|
||||
}
|
||||
|
||||
/// The control signal for MIDI controller `controller`, optionally
|
||||
/// creating it. Owned by the track.
|
||||
public func signalForController(_ controller: Int, create: Bool = false) -> ControlSignal? {
|
||||
guard let signal = SequenceTrack.api.getSignalForController.unsafelyUnwrapped(
|
||||
pointer, Int32(controller), create ? 1 : 0) else { return nil }
|
||||
return ControlSignal(pointer: signal, isOwned: false)
|
||||
}
|
||||
|
||||
public func clearControlEvents() {
|
||||
SequenceTrack.api.clearControlEvents.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// The maximum number of simultaneous notes in the track.
|
||||
public var polyphony: Int {
|
||||
Int(SequenceTrack.api.getPolyphony.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
public var activeVoiceCount: Int {
|
||||
Int(SequenceTrack.api.activeVoiceCount.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
public func setMuted(_ muted: Bool) {
|
||||
SequenceTrack.api.setMuted.unsafelyUnwrapped(pointer, muted ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sequence
|
||||
|
||||
/// A collection of tracks with tempo and loop control, playable from a
|
||||
/// MIDI file. Wraps `SoundSequence`.
|
||||
public final class Sequence {
|
||||
private static var api: playdate_sound_sequence { snd.sequence.pointee }
|
||||
|
||||
let pointer: OpaquePointer
|
||||
private var retainedTracks: [SequenceTrack] = []
|
||||
var finishCallback: ((Sequence) -> Void)?
|
||||
|
||||
public init() {
|
||||
pointer = Sequence.api.newSequence.unsafelyUnwrapped().unsafelyUnwrapped
|
||||
}
|
||||
|
||||
/// Creates a sequence and loads the MIDI file at `path`.
|
||||
public convenience init(midiFilePath: String) throws(Playdate.Error) {
|
||||
self.init()
|
||||
try loadMIDIFile(path: midiFilePath)
|
||||
}
|
||||
|
||||
deinit {
|
||||
Sequence.api.freeSequence.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
public func loadMIDIFile(path: String) throws(Playdate.Error) {
|
||||
let loaded = path.withPlaydateCString {
|
||||
Sequence.api.loadMIDIFile.unsafelyUnwrapped(pointer, $0) != 0
|
||||
}
|
||||
if !loaded {
|
||||
throw Playdate.Error(message: "unable to load MIDI file: \(path)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts playback. `completion` is called when the sequence finishes.
|
||||
public func play(completion: ((Sequence) -> Void)? = nil) {
|
||||
finishCallback = completion
|
||||
if completion != nil {
|
||||
Sequence.api.play.unsafelyUnwrapped(pointer, { _, userdata in
|
||||
guard let userdata else { return }
|
||||
let sequence = Unmanaged<Sequence>.fromOpaque(userdata).takeUnretainedValue()
|
||||
sequence.finishCallback?(sequence)
|
||||
}, Unmanaged.passUnretained(self).toOpaque())
|
||||
} else {
|
||||
Sequence.api.play.unsafelyUnwrapped(pointer, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
Sequence.api.stop.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
public var isPlaying: Bool {
|
||||
Sequence.api.isPlaying.unsafelyUnwrapped(pointer) != 0
|
||||
}
|
||||
|
||||
/// The playback position, in samples.
|
||||
public var time: UInt32 {
|
||||
get { Sequence.api.getTime.unsafelyUnwrapped(pointer) }
|
||||
set { Sequence.api.setTime.unsafelyUnwrapped(pointer, newValue) }
|
||||
}
|
||||
|
||||
/// The tempo, in steps per second.
|
||||
public var tempo: Float {
|
||||
get { Sequence.api.getTempo.unsafelyUnwrapped(pointer) }
|
||||
set { Sequence.api.setTempo.unsafelyUnwrapped(pointer, newValue) }
|
||||
}
|
||||
|
||||
/// The sequence's length in steps, including the tail of the last note.
|
||||
public var length: UInt32 {
|
||||
Sequence.api.getLength.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Loops the range `loopStart..<loopEnd` (steps) `loops` times while
|
||||
/// playing; 0 loops endlessly.
|
||||
public func setLoops(start: Int, end: Int, count: Int = 0) {
|
||||
Sequence.api.setLoops.unsafelyUnwrapped(pointer, Int32(start), Int32(end), Int32(count))
|
||||
}
|
||||
|
||||
/// The current step, and the time offset (in samples) into that step.
|
||||
public var currentStep: (step: Int, timeOffset: Int) {
|
||||
var timeOffset: Int32 = 0
|
||||
let step = Sequence.api.getCurrentStep.unsafelyUnwrapped(pointer, &timeOffset)
|
||||
return (Int(step), Int(timeOffset))
|
||||
}
|
||||
|
||||
/// Moves playback to the given step. If `playNotes` is `true`, notes
|
||||
/// at the position (that started before it) are played.
|
||||
public func setCurrentStep(_ step: Int, timeOffset: Int = 0, playNotes: Bool = false) {
|
||||
Sequence.api.setCurrentStep.unsafelyUnwrapped(pointer, Int32(step),
|
||||
Int32(timeOffset), playNotes ? 1 : 0)
|
||||
}
|
||||
|
||||
// MARK: Tracks
|
||||
|
||||
public var trackCount: Int {
|
||||
Int(Sequence.api.getTrackCount.unsafelyUnwrapped(pointer))
|
||||
}
|
||||
|
||||
/// Adds a new track to the sequence. The track is owned by the
|
||||
/// sequence.
|
||||
@discardableResult
|
||||
public func addTrack() -> SequenceTrack {
|
||||
let track = SequenceTrack(
|
||||
pointer: Sequence.api.addTrack.unsafelyUnwrapped(pointer).unsafelyUnwrapped,
|
||||
isOwned: false)
|
||||
retainedTracks.append(track)
|
||||
return track
|
||||
}
|
||||
|
||||
/// The track at `index`. Owned by the sequence.
|
||||
public func track(at index: Int) -> SequenceTrack? {
|
||||
guard let track = Sequence.api.getTrackAtIndex.unsafelyUnwrapped(
|
||||
pointer, UInt32(index)) else { return nil }
|
||||
return SequenceTrack(pointer: track, isOwned: false)
|
||||
}
|
||||
|
||||
/// Installs `track` at `index`.
|
||||
public func setTrack(_ track: SequenceTrack, at index: Int) {
|
||||
if !retainedTracks.contains(where: { $0 === track }) {
|
||||
retainedTracks.append(track)
|
||||
}
|
||||
Sequence.api.setTrackAtIndex.unsafelyUnwrapped(pointer, track.pointer, UInt32(index))
|
||||
}
|
||||
|
||||
/// Releases every playing note in the sequence.
|
||||
public func allNotesOff() {
|
||||
Sequence.api.allNotesOff.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
//
|
||||
// Sprite.swift
|
||||
// Wraps `playdate->sprite` (pd_api_sprite.h).
|
||||
//
|
||||
// The binding stores a back-reference to each `Sprite` wrapper in the
|
||||
// underlying `LCDSprite`'s userdata slot, so callbacks and queries can
|
||||
// recover the wrapper. Do not mix these wrappers with C code that sets its
|
||||
// own sprite userdata; use `Sprite.userdata` for per-sprite storage instead.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
private var spriteAPI: playdate_sprite { Playdate.api.sprite.pointee }
|
||||
|
||||
extension Playdate {
|
||||
/// A floating-point rectangle mirroring `PDRect`.
|
||||
public struct Rect: Sendable {
|
||||
public var x: Float
|
||||
public var y: Float
|
||||
public var width: Float
|
||||
public var height: Float
|
||||
|
||||
public init(x: Float, y: Float, width: Float, height: Float) {
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
init(_ rect: PDRect) {
|
||||
self.init(x: rect.x, y: rect.y, width: rect.width, height: rect.height)
|
||||
}
|
||||
|
||||
var cValue: PDRect { PDRect(x: x, y: y, width: width, height: height) }
|
||||
}
|
||||
}
|
||||
|
||||
extension Playdate {
|
||||
/// A sprite: a drawable object with position, z-order, and collision
|
||||
/// support. Wraps `LCDSprite`. Static members wrap the global sprite
|
||||
/// system functions.
|
||||
public final class Sprite {
|
||||
let pointer: OpaquePointer
|
||||
let isOwned: Bool
|
||||
|
||||
/// Per-sprite callbacks and retained resources.
|
||||
var updateFunction: ((Sprite) -> Void)?
|
||||
var drawFunction: ((Sprite, _ bounds: Rect, _ drawRect: Rect) -> Void)?
|
||||
var collisionResponseFunction: ((Sprite, _ other: Sprite) -> CollisionResponse)?
|
||||
private var retainedImage: Graphics.Bitmap?
|
||||
private var retainedStencil: Graphics.Bitmap?
|
||||
private var retainedTilemap: Graphics.TileMap?
|
||||
|
||||
/// Free-form storage for game use (the C userdata slot is reserved
|
||||
/// by the binding).
|
||||
public var userdata: AnyObject?
|
||||
|
||||
init(pointer: OpaquePointer, isOwned: Bool) {
|
||||
self.pointer = pointer
|
||||
self.isOwned = isOwned
|
||||
spriteAPI.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque())
|
||||
}
|
||||
|
||||
/// Allocates a new sprite.
|
||||
public convenience init() {
|
||||
self.init(pointer: spriteAPI.newSprite.unsafelyUnwrapped().unsafelyUnwrapped, isOwned: true)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
spriteAPI.setUserdata.unsafelyUnwrapped(pointer, nil)
|
||||
spriteAPI.freeSprite.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the Swift wrapper stored in the sprite's userdata, or a
|
||||
/// transient unowned wrapper for sprites created outside the binding.
|
||||
static func wrapper(for pointer: OpaquePointer) -> Sprite {
|
||||
if let userdata = spriteAPI.getUserdata.unsafelyUnwrapped(pointer) {
|
||||
return Unmanaged<Sprite>.fromOpaque(userdata).takeUnretainedValue()
|
||||
}
|
||||
return Sprite(pointer: pointer, isOwned: false)
|
||||
}
|
||||
|
||||
/// Copies the sprite. Callbacks and retained resources are carried
|
||||
/// over to the copy.
|
||||
public func copy() -> Sprite {
|
||||
let copy = Sprite(pointer: spriteAPI.copy.unsafelyUnwrapped(pointer).unsafelyUnwrapped,
|
||||
isOwned: true)
|
||||
copy.updateFunction = updateFunction
|
||||
copy.drawFunction = drawFunction
|
||||
copy.collisionResponseFunction = collisionResponseFunction
|
||||
copy.retainedImage = retainedImage
|
||||
copy.retainedStencil = retainedStencil
|
||||
copy.retainedTilemap = retainedTilemap
|
||||
return copy
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
/// How a sprite reacts when a collision occurs.
|
||||
public enum CollisionResponse: UInt32, Sendable {
|
||||
case slide = 0
|
||||
case freeze = 1
|
||||
case overlap = 2
|
||||
case bounce = 3
|
||||
|
||||
init(_ response: SpriteCollisionResponseType) {
|
||||
self = CollisionResponse(rawValue: response.rawValue) ?? .freeze
|
||||
}
|
||||
var cValue: SpriteCollisionResponseType { SpriteCollisionResponseType(rawValue) }
|
||||
}
|
||||
|
||||
/// Information about a single collision, mirroring `SpriteCollisionInfo`.
|
||||
public struct CollisionInfo {
|
||||
/// The sprite being moved.
|
||||
public let sprite: Sprite
|
||||
/// The sprite it collided with.
|
||||
public let other: Sprite
|
||||
/// The collision response used.
|
||||
public let response: CollisionResponse
|
||||
/// `true` if the sprites were overlapping when the collision
|
||||
/// started; `false` if the sprite tunneled through.
|
||||
public let overlaps: Bool
|
||||
/// How far along the movement (0...1) the collision occurred.
|
||||
public let ti: Float
|
||||
/// The difference between the requested and actual positions.
|
||||
public let move: (x: Float, y: Float)
|
||||
/// The collision normal (each component -1, 0, or 1).
|
||||
public let normal: (x: Int, y: Int)
|
||||
/// Where the sprite started touching `other`.
|
||||
public let touch: (x: Float, y: Float)
|
||||
/// The sprite's rect at the moment of the touch.
|
||||
public let spriteRect: Playdate.Rect
|
||||
/// `other`'s rect at the moment of the touch.
|
||||
public let otherRect: Playdate.Rect
|
||||
|
||||
init(_ info: SpriteCollisionInfo) {
|
||||
sprite = Sprite.wrapper(for: info.sprite)
|
||||
other = Sprite.wrapper(for: info.other)
|
||||
response = CollisionResponse(info.responseType)
|
||||
overlaps = info.overlaps != 0
|
||||
ti = info.ti
|
||||
move = (info.move.x, info.move.y)
|
||||
normal = (Int(info.normal.x), Int(info.normal.y))
|
||||
touch = (info.touch.x, info.touch.y)
|
||||
spriteRect = Playdate.Rect(info.spriteRect)
|
||||
otherRect = Playdate.Rect(info.otherRect)
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a sprite intersected by a line segment,
|
||||
/// mirroring `SpriteQueryInfo`.
|
||||
public struct QueryInfo {
|
||||
public let sprite: Sprite
|
||||
/// How far along the segment (0...1) the segment enters the sprite.
|
||||
public let ti1: Float
|
||||
/// How far along the segment (0...1) the segment exits the sprite.
|
||||
public let ti2: Float
|
||||
public let entryPoint: (x: Float, y: Float)
|
||||
public let exitPoint: (x: Float, y: Float)
|
||||
|
||||
init(_ info: SpriteQueryInfo) {
|
||||
sprite = Sprite.wrapper(for: info.sprite)
|
||||
ti1 = info.ti1
|
||||
ti2 = info.ti2
|
||||
entryPoint = (info.entryPoint.x, info.entryPoint.y)
|
||||
exitPoint = (info.exitPoint.x, info.exitPoint.y)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display list
|
||||
|
||||
/// Sprites currently added to the display list, kept alive here.
|
||||
nonisolated(unsafe) private static var displayList: [Sprite] = []
|
||||
|
||||
/// When `true`, all sprites redraw every frame instead of only when
|
||||
/// marked dirty.
|
||||
public static func setAlwaysRedraw(_ flag: Bool) {
|
||||
spriteAPI.setAlwaysRedraw.unsafelyUnwrapped(flag ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Marks the given screen region as needing a redraw.
|
||||
public static func addDirtyRect(_ rect: Graphics.Rect) {
|
||||
spriteAPI.addDirtyRect.unsafelyUnwrapped(rect.cValue)
|
||||
}
|
||||
|
||||
/// Draws every sprite in the display list.
|
||||
public static func drawAll() {
|
||||
spriteAPI.drawSprites.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
/// Updates and then draws every sprite in the display list.
|
||||
public static func updateAndDrawAll() {
|
||||
spriteAPI.updateAndDrawSprites.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
/// The number of sprites in the display list.
|
||||
public static var count: Int {
|
||||
Int(spriteAPI.getSpriteCount.unsafelyUnwrapped())
|
||||
}
|
||||
|
||||
/// Adds the sprite to the display list.
|
||||
public func add() {
|
||||
spriteAPI.addSprite.unsafelyUnwrapped(pointer)
|
||||
if !Sprite.displayList.contains(where: { $0 === self }) {
|
||||
Sprite.displayList.append(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the sprite from the display list.
|
||||
public func remove() {
|
||||
spriteAPI.removeSprite.unsafelyUnwrapped(pointer)
|
||||
Sprite.displayList.removeAll { $0 === self }
|
||||
}
|
||||
|
||||
/// Removes the given sprites from the display list.
|
||||
public static func remove(_ sprites: [Sprite]) {
|
||||
for sprite in sprites { sprite.remove() }
|
||||
}
|
||||
|
||||
/// Removes every sprite from the display list.
|
||||
public static func removeAll() {
|
||||
spriteAPI.removeAllSprites.unsafelyUnwrapped()
|
||||
displayList = []
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
/// The sprite's bounds. Setting this positions and sizes the sprite.
|
||||
public var bounds: Playdate.Rect {
|
||||
get { Playdate.Rect(spriteAPI.getBounds.unsafelyUnwrapped(pointer)) }
|
||||
set { spriteAPI.setBounds.unsafelyUnwrapped(pointer, newValue.cValue) }
|
||||
}
|
||||
|
||||
/// Moves the sprite so its anchor point is at (x, y).
|
||||
public func moveTo(x: Float, y: Float) {
|
||||
spriteAPI.moveTo.unsafelyUnwrapped(pointer, x, y)
|
||||
}
|
||||
|
||||
/// Moves the sprite by (dx, dy).
|
||||
public func moveBy(dx: Float, dy: Float) {
|
||||
spriteAPI.moveBy.unsafelyUnwrapped(pointer, dx, dy)
|
||||
}
|
||||
|
||||
/// The sprite's anchor position.
|
||||
public var position: (x: Float, y: Float) {
|
||||
var x: Float = 0, y: Float = 0
|
||||
spriteAPI.getPosition.unsafelyUnwrapped(pointer, &x, &y)
|
||||
return (x, y)
|
||||
}
|
||||
|
||||
/// Sets the sprite's size without changing its image.
|
||||
public func setSize(width: Float, height: Float) {
|
||||
spriteAPI.setSize.unsafelyUnwrapped(pointer, width, height)
|
||||
}
|
||||
|
||||
/// The anchor point used for positioning, where (0, 0) is the top
|
||||
/// left and (1, 1) the bottom right. Defaults to (0.5, 0.5).
|
||||
public var center: (x: Float, y: Float) {
|
||||
get {
|
||||
var x: Float = 0, y: Float = 0
|
||||
spriteAPI.getCenter.unsafelyUnwrapped(pointer, &x, &y)
|
||||
return (x, y)
|
||||
}
|
||||
set { spriteAPI.setCenter.unsafelyUnwrapped(pointer, newValue.x, newValue.y) }
|
||||
}
|
||||
|
||||
/// Draw order: higher values draw on top.
|
||||
public var zIndex: Int16 {
|
||||
get { spriteAPI.getZIndex.unsafelyUnwrapped(pointer) }
|
||||
set { spriteAPI.setZIndex.unsafelyUnwrapped(pointer, newValue) }
|
||||
}
|
||||
|
||||
// MARK: - Appearance
|
||||
|
||||
/// Sets the sprite's image, resizing its bounds to match.
|
||||
public func setImage(_ image: Graphics.Bitmap?, flip: Graphics.BitmapFlip = .unflipped) {
|
||||
retainedImage = image
|
||||
spriteAPI.setImage.unsafelyUnwrapped(pointer, image?.pointer, flip.cValue)
|
||||
}
|
||||
|
||||
/// The sprite's image.
|
||||
public var image: Graphics.Bitmap? {
|
||||
if let retainedImage { return retainedImage }
|
||||
guard let image = spriteAPI.getImage.unsafelyUnwrapped(pointer) else { return nil }
|
||||
return Graphics.Bitmap(pointer: image, isOwned: false)
|
||||
}
|
||||
|
||||
/// Sets the sprite's tilemap, resizing its bounds to match.
|
||||
public var tilemap: Graphics.TileMap? {
|
||||
get { retainedTilemap }
|
||||
set {
|
||||
retainedTilemap = newValue
|
||||
spriteAPI.setTilemap.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode used to draw the sprite's image.
|
||||
public func setDrawMode(_ mode: Graphics.DrawMode) {
|
||||
spriteAPI.setDrawMode.unsafelyUnwrapped(pointer, mode.cValue)
|
||||
}
|
||||
|
||||
/// How the sprite's image is mirrored when drawn.
|
||||
public var imageFlip: Graphics.BitmapFlip {
|
||||
get { Graphics.BitmapFlip(spriteAPI.getImageFlip.unsafelyUnwrapped(pointer)) }
|
||||
set { spriteAPI.setImageFlip.unsafelyUnwrapped(pointer, newValue.cValue) }
|
||||
}
|
||||
|
||||
/// Sets the stencil applied when drawing the sprite. If `tile` is
|
||||
/// `true` the image width must be a multiple of 32.
|
||||
public func setStencil(_ stencil: Graphics.Bitmap?, tile: Bool = false) {
|
||||
retainedStencil = stencil
|
||||
spriteAPI.setStencilImage.unsafelyUnwrapped(pointer, stencil?.pointer, tile ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Sets an 8×8 stencil pattern (8 rows of image data).
|
||||
public func setStencilPattern(_ rows: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) {
|
||||
var pattern: [UInt8] = [rows.0, rows.1, rows.2, rows.3, rows.4, rows.5, rows.6, rows.7]
|
||||
pattern.withUnsafeMutableBufferPointer { buffer in
|
||||
spriteAPI.setStencilPattern.unsafelyUnwrapped(pointer, buffer.baseAddress)
|
||||
}
|
||||
}
|
||||
|
||||
public func clearStencil() {
|
||||
retainedStencil = nil
|
||||
spriteAPI.clearStencil.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Clips the sprite's drawing to `rect` (screen coordinates).
|
||||
public func setClipRect(_ rect: Graphics.Rect) {
|
||||
spriteAPI.setClipRect.unsafelyUnwrapped(pointer, rect.cValue)
|
||||
}
|
||||
|
||||
public func clearClipRect() {
|
||||
spriteAPI.clearClipRect.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Clips all sprites with z-index in `startZ...endZ` to `rect`.
|
||||
public static func setClipRectsInRange(_ rect: Graphics.Rect, startZ: Int, endZ: Int) {
|
||||
spriteAPI.setClipRectsInRange.unsafelyUnwrapped(rect.cValue, Int32(startZ), Int32(endZ))
|
||||
}
|
||||
|
||||
public static func clearClipRectsInRange(startZ: Int, endZ: Int) {
|
||||
spriteAPI.clearClipRectsInRange.unsafelyUnwrapped(Int32(startZ), Int32(endZ))
|
||||
}
|
||||
|
||||
// MARK: - Behavior flags
|
||||
|
||||
/// Whether the sprite's update function is called by `updateAndDrawAll()`.
|
||||
public var updatesEnabled: Bool {
|
||||
get { spriteAPI.updatesEnabled.unsafelyUnwrapped(pointer) != 0 }
|
||||
set { spriteAPI.setUpdatesEnabled.unsafelyUnwrapped(pointer, newValue ? 1 : 0) }
|
||||
}
|
||||
|
||||
public var collisionsEnabled: Bool {
|
||||
get { spriteAPI.collisionsEnabled.unsafelyUnwrapped(pointer) != 0 }
|
||||
set { spriteAPI.setCollisionsEnabled.unsafelyUnwrapped(pointer, newValue ? 1 : 0) }
|
||||
}
|
||||
|
||||
public var isVisible: Bool {
|
||||
get { spriteAPI.isVisible.unsafelyUnwrapped(pointer) != 0 }
|
||||
set { spriteAPI.setVisible.unsafelyUnwrapped(pointer, newValue ? 1 : 0) }
|
||||
}
|
||||
|
||||
/// Marking a sprite opaque tells the system it does not need to redraw
|
||||
/// anything behind it.
|
||||
public func setOpaque(_ flag: Bool) {
|
||||
spriteAPI.setOpaque.unsafelyUnwrapped(pointer, flag ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Forces the sprite to redraw this frame.
|
||||
public func markDirty() {
|
||||
spriteAPI.markDirty.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Marks part of the sprite (in sprite-local coordinates) as needing
|
||||
/// a redraw.
|
||||
public func markDirty(rect: Playdate.Rect) {
|
||||
spriteAPI.markDirtyRect.unsafelyUnwrapped(pointer, rect.cValue)
|
||||
}
|
||||
|
||||
/// An integer tag for identifying sprites (e.g. in collisions).
|
||||
public var tag: UInt8 {
|
||||
get { spriteAPI.getTag.unsafelyUnwrapped(pointer) }
|
||||
set { spriteAPI.setTag.unsafelyUnwrapped(pointer, newValue) }
|
||||
}
|
||||
|
||||
/// When `true`, the sprite draws in screen coordinates, ignoring the
|
||||
/// global draw offset.
|
||||
public func setIgnoresDrawOffset(_ flag: Bool) {
|
||||
spriteAPI.setIgnoresDrawOffset.unsafelyUnwrapped(pointer, flag ? 1 : 0)
|
||||
}
|
||||
|
||||
// MARK: - Callbacks
|
||||
|
||||
/// Sets the function called by `updateAndDrawAll()` for this sprite.
|
||||
public func setUpdateFunction(_ update: ((Sprite) -> Void)?) {
|
||||
updateFunction = update
|
||||
if update != nil {
|
||||
spriteAPI.setUpdateFunction.unsafelyUnwrapped(pointer, { spritePointer in
|
||||
guard let spritePointer else { return }
|
||||
let sprite = Sprite.wrapper(for: spritePointer)
|
||||
sprite.updateFunction?(sprite)
|
||||
})
|
||||
} else {
|
||||
spriteAPI.setUpdateFunction.unsafelyUnwrapped(pointer, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a custom draw function, called when the sprite needs to draw.
|
||||
/// `bounds` is the sprite's bounds; `drawRect` is the region that
|
||||
/// needs redrawing.
|
||||
public func setDrawFunction(_ draw: ((Sprite, _ bounds: Playdate.Rect, _ drawRect: Playdate.Rect) -> Void)?) {
|
||||
drawFunction = draw
|
||||
if draw != nil {
|
||||
spriteAPI.setDrawFunction.unsafelyUnwrapped(pointer, { spritePointer, bounds, drawRect in
|
||||
guard let spritePointer else { return }
|
||||
let sprite = Sprite.wrapper(for: spritePointer)
|
||||
sprite.drawFunction?(sprite, Playdate.Rect(bounds), Playdate.Rect(drawRect))
|
||||
})
|
||||
} else {
|
||||
spriteAPI.setDrawFunction.unsafelyUnwrapped(pointer, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Collisions
|
||||
|
||||
/// Clears the collision world. Call when changing scenes.
|
||||
public static func resetCollisionWorld() {
|
||||
spriteAPI.resetCollisionWorld.unsafelyUnwrapped()
|
||||
}
|
||||
|
||||
/// The rect (in sprite-local coordinates) used for collisions.
|
||||
public var collideRect: Playdate.Rect {
|
||||
get { Playdate.Rect(spriteAPI.getCollideRect.unsafelyUnwrapped(pointer)) }
|
||||
set { spriteAPI.setCollideRect.unsafelyUnwrapped(pointer, newValue.cValue) }
|
||||
}
|
||||
|
||||
public func clearCollideRect() {
|
||||
spriteAPI.clearCollideRect.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
|
||||
/// Sets the function deciding how this sprite responds when it
|
||||
/// collides with `other`.
|
||||
public func setCollisionResponseFunction(_ filter: ((Sprite, _ other: Sprite) -> CollisionResponse)?) {
|
||||
collisionResponseFunction = filter
|
||||
if filter != nil {
|
||||
spriteAPI.setCollisionResponseFunction.unsafelyUnwrapped(pointer, { spritePointer, otherPointer in
|
||||
guard let spritePointer, let otherPointer else { return kCollisionTypeFreeze }
|
||||
let sprite = Sprite.wrapper(for: spritePointer)
|
||||
let other = Sprite.wrapper(for: otherPointer)
|
||||
return sprite.collisionResponseFunction?(sprite, other).cValue ?? kCollisionTypeFreeze
|
||||
})
|
||||
} else {
|
||||
spriteAPI.setCollisionResponseFunction.unsafelyUnwrapped(pointer, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts and frees a C collision info array.
|
||||
private static func collisionInfos(_ pointer: UnsafeMutablePointer<SpriteCollisionInfo>?,
|
||||
count: Int32) -> [CollisionInfo] {
|
||||
guard let pointer else { return [] }
|
||||
var infos = [CollisionInfo]()
|
||||
infos.reserveCapacity(Int(count))
|
||||
for index in 0..<Int(count) {
|
||||
infos.append(CollisionInfo(pointer[index]))
|
||||
}
|
||||
Playdate.System.systemFree(pointer)
|
||||
return infos
|
||||
}
|
||||
|
||||
/// Returns the collisions that would occur if the sprite moved toward
|
||||
/// (goalX, goalY), without moving it.
|
||||
public func checkCollisions(goalX: Float, goalY: Float)
|
||||
-> (actual: (x: Float, y: Float), collisions: [CollisionInfo]) {
|
||||
var actualX: Float = 0, actualY: Float = 0, count: Int32 = 0
|
||||
let result = spriteAPI.checkCollisions.unsafelyUnwrapped(
|
||||
pointer, goalX, goalY, &actualX, &actualY, &count)
|
||||
return ((actualX, actualY), Sprite.collisionInfos(result, count: count))
|
||||
}
|
||||
|
||||
/// Moves the sprite toward (goalX, goalY), resolving collisions, and
|
||||
/// returns where it ended up and what it hit.
|
||||
@discardableResult
|
||||
public func moveWithCollisions(goalX: Float, goalY: Float)
|
||||
-> (actual: (x: Float, y: Float), collisions: [CollisionInfo]) {
|
||||
var actualX: Float = 0, actualY: Float = 0, count: Int32 = 0
|
||||
let result = spriteAPI.moveWithCollisions.unsafelyUnwrapped(
|
||||
pointer, goalX, goalY, &actualX, &actualY, &count)
|
||||
return ((actualX, actualY), Sprite.collisionInfos(result, count: count))
|
||||
}
|
||||
|
||||
/// Converts and frees a C sprite pointer array.
|
||||
private static func sprites(_ pointer: UnsafeMutablePointer<OpaquePointer?>?,
|
||||
count: Int32) -> [Sprite] {
|
||||
guard let pointer else { return [] }
|
||||
var sprites = [Sprite]()
|
||||
sprites.reserveCapacity(Int(count))
|
||||
for index in 0..<Int(count) {
|
||||
if let spritePointer = pointer[index] {
|
||||
sprites.append(wrapper(for: spritePointer))
|
||||
}
|
||||
}
|
||||
Playdate.System.systemFree(pointer)
|
||||
return sprites
|
||||
}
|
||||
|
||||
/// Sprites with collision rects containing the point.
|
||||
public static func query(atPoint x: Float, _ y: Float) -> [Sprite] {
|
||||
var count: Int32 = 0
|
||||
let result = spriteAPI.querySpritesAtPoint.unsafelyUnwrapped(x, y, &count)
|
||||
return sprites(result, count: count)
|
||||
}
|
||||
|
||||
/// Sprites with collision rects intersecting the rect.
|
||||
public static func query(inRect x: Float, _ y: Float, width: Float, height: Float) -> [Sprite] {
|
||||
var count: Int32 = 0
|
||||
let result = spriteAPI.querySpritesInRect.unsafelyUnwrapped(x, y, width, height, &count)
|
||||
return sprites(result, count: count)
|
||||
}
|
||||
|
||||
/// Sprites with collision rects intersecting the line segment.
|
||||
public static func query(alongLine x1: Float, _ y1: Float, _ x2: Float, _ y2: Float) -> [Sprite] {
|
||||
var count: Int32 = 0
|
||||
let result = spriteAPI.querySpritesAlongLine.unsafelyUnwrapped(x1, y1, x2, y2, &count)
|
||||
return sprites(result, count: count)
|
||||
}
|
||||
|
||||
/// Like `query(alongLine:)`, with entry/exit information for each sprite.
|
||||
public static func queryInfo(alongLine x1: Float, _ y1: Float,
|
||||
_ x2: Float, _ y2: Float) -> [QueryInfo] {
|
||||
var count: Int32 = 0
|
||||
guard let result = spriteAPI.querySpriteInfoAlongLine.unsafelyUnwrapped(
|
||||
x1, y1, x2, y2, &count) else { return [] }
|
||||
var infos = [QueryInfo]()
|
||||
infos.reserveCapacity(Int(count))
|
||||
for index in 0..<Int(count) {
|
||||
infos.append(QueryInfo(result[index]))
|
||||
}
|
||||
Playdate.System.systemFree(result)
|
||||
return infos
|
||||
}
|
||||
|
||||
/// Sprites whose collision rects overlap this sprite's.
|
||||
public var overlappingSprites: [Sprite] {
|
||||
var count: Int32 = 0
|
||||
let result = spriteAPI.overlappingSprites.unsafelyUnwrapped(pointer, &count)
|
||||
return Sprite.sprites(result, count: count)
|
||||
}
|
||||
|
||||
/// All sprites in the display list that overlap another sprite.
|
||||
public static var allOverlappingSprites: [Sprite] {
|
||||
var count: Int32 = 0
|
||||
let result = spriteAPI.allOverlappingSprites.unsafelyUnwrapped(&count)
|
||||
return sprites(result, count: count)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// Support.swift
|
||||
// Internal helpers shared by the wrappers.
|
||||
//
|
||||
// C-string conversions are implemented manually (rather than with
|
||||
// `String(cString:)` / `withCString`) so the module stays within the
|
||||
// Embedded Swift subset used for device builds.
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
extension String {
|
||||
/// Creates a string by copying a null-terminated UTF-8 C string.
|
||||
init(playdateCString pointer: UnsafePointer<CChar>) {
|
||||
var count = 0
|
||||
while pointer[count] != 0 { count += 1 }
|
||||
let bytes = UnsafeRawBufferPointer(start: pointer, count: count)
|
||||
self = String(decoding: bytes, as: UTF8.self)
|
||||
}
|
||||
|
||||
/// Creates a string from a nullable C string, or `nil` if the pointer is null.
|
||||
init?(playdateCString pointer: UnsafePointer<CChar>?) {
|
||||
guard let pointer else { return nil }
|
||||
self.init(playdateCString: pointer)
|
||||
}
|
||||
|
||||
/// Calls `body` with a temporary null-terminated UTF-8 copy of the string.
|
||||
func withPlaydateCString<Result>(_ body: (UnsafePointer<CChar>) -> Result) -> Result {
|
||||
var utf8 = ContiguousArray(self.utf8)
|
||||
utf8.append(0)
|
||||
return utf8.withUnsafeBufferPointer { buffer in
|
||||
buffer.withMemoryRebound(to: CChar.self) { rebound in
|
||||
body(rebound.baseAddress.unsafelyUnwrapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the string into a newly allocated null-terminated C string.
|
||||
/// The caller owns the memory and must free it with `deallocate()`.
|
||||
func copiedPlaydateCString() -> UnsafeMutablePointer<CChar> {
|
||||
let utf8 = ContiguousArray(self.utf8)
|
||||
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: utf8.count + 1)
|
||||
for (index, byte) in utf8.enumerated() {
|
||||
buffer[index] = CChar(bitPattern: byte)
|
||||
}
|
||||
buffer[utf8.count] = 0
|
||||
return buffer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//
|
||||
// System.swift
|
||||
// Wraps `playdate->system` (pd_api_sys.h).
|
||||
//
|
||||
|
||||
internal import CPlaydate
|
||||
|
||||
extension Playdate {
|
||||
/// The system API: logging, input, time, menu items, and device state.
|
||||
public enum System {}
|
||||
}
|
||||
|
||||
extension Playdate.System {
|
||||
private static var api: playdate_sys { Playdate.api.system.pointee }
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
/// The state of the d-pad and face buttons, as an option set.
|
||||
public struct Buttons: OptionSet, Sendable {
|
||||
public let rawValue: UInt32
|
||||
public init(rawValue: UInt32) { self.rawValue = rawValue }
|
||||
init(_ buttons: PDButtons) { self.rawValue = buttons.rawValue }
|
||||
var cValue: PDButtons { PDButtons(rawValue) }
|
||||
|
||||
public static let left = Buttons(kButtonLeft)
|
||||
public static let right = Buttons(kButtonRight)
|
||||
public static let up = Buttons(kButtonUp)
|
||||
public static let down = Buttons(kButtonDown)
|
||||
public static let b = Buttons(kButtonB)
|
||||
public static let a = Buttons(kButtonA)
|
||||
}
|
||||
|
||||
/// Peripherals that can be enabled with `setPeripheralsEnabled(_:)`.
|
||||
public struct Peripherals: OptionSet, Sendable {
|
||||
public let rawValue: UInt32
|
||||
public init(rawValue: UInt32) { self.rawValue = rawValue }
|
||||
|
||||
public static let none = Peripherals([])
|
||||
public static let accelerometer = Peripherals(rawValue: kAccelerometer.rawValue)
|
||||
public static let all = Peripherals(rawValue: kAllPeripherals.rawValue)
|
||||
}
|
||||
|
||||
/// The system language.
|
||||
public enum Language: UInt32, Sendable {
|
||||
case english = 0
|
||||
case japanese = 1
|
||||
/// Only meaningful as an argument to `localizedText(forKey:language:)`.
|
||||
case system = 2
|
||||
|
||||
init(_ language: PDLanguage) {
|
||||
self = Language(rawValue: language.rawValue) ?? .english
|
||||
}
|
||||
var cValue: PDLanguage { PDLanguage(rawValue) }
|
||||
}
|
||||
|
||||
/// A calendar date and time, mirroring `PDDateTime`.
|
||||
public struct DateTime: Sendable {
|
||||
public var year: UInt16
|
||||
/// 1...12
|
||||
public var month: UInt8
|
||||
/// 1...31
|
||||
public var day: UInt8
|
||||
/// 1 = Monday ... 7 = Sunday
|
||||
public var weekday: UInt8
|
||||
/// 0...23
|
||||
public var hour: UInt8
|
||||
public var minute: UInt8
|
||||
public var second: UInt8
|
||||
|
||||
public init(year: UInt16, month: UInt8, day: UInt8, weekday: UInt8 = 0,
|
||||
hour: UInt8, minute: UInt8, second: UInt8) {
|
||||
self.year = year
|
||||
self.month = month
|
||||
self.day = day
|
||||
self.weekday = weekday
|
||||
self.hour = hour
|
||||
self.minute = minute
|
||||
self.second = second
|
||||
}
|
||||
|
||||
init(_ dateTime: PDDateTime) {
|
||||
year = dateTime.year
|
||||
month = dateTime.month
|
||||
day = dateTime.day
|
||||
weekday = dateTime.weekday
|
||||
hour = dateTime.hour
|
||||
minute = dateTime.minute
|
||||
second = dateTime.second
|
||||
}
|
||||
|
||||
var cValue: PDDateTime {
|
||||
PDDateTime(year: year, month: month, day: day, weekday: weekday,
|
||||
hour: hour, minute: minute, second: second)
|
||||
}
|
||||
}
|
||||
|
||||
/// Battery and power supply state.
|
||||
public struct PowerStatus: OptionSet, Sendable {
|
||||
public let rawValue: UInt32
|
||||
public init(rawValue: UInt32) { self.rawValue = rawValue }
|
||||
|
||||
public static let charging = PowerStatus(rawValue: kPDPowerStatusCharging.rawValue)
|
||||
public static let usb = PowerStatus(rawValue: kPDPowerStatusUsb.rawValue)
|
||||
public static let screws = PowerStatus(rawValue: kPDPowerStatusScrews.rawValue)
|
||||
}
|
||||
|
||||
/// OS, language, and pdx version information, mirroring `PDInfo`.
|
||||
public struct Info: Sendable {
|
||||
public let osVersion: UInt32
|
||||
public let language: Language
|
||||
public let pdxVersion: UInt32
|
||||
}
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
/// The system allocator. Pass `nil` to allocate, `size` 0 to free.
|
||||
@discardableResult
|
||||
public static func realloc(_ pointer: UnsafeMutableRawPointer?, size: Int) -> UnsafeMutableRawPointer? {
|
||||
api.realloc.unsafelyUnwrapped(pointer, size)
|
||||
}
|
||||
|
||||
/// Frees memory that the Playdate OS handed to the caller (e.g. strings
|
||||
/// returned by `localizedText(forKey:)`).
|
||||
static func systemFree(_ pointer: UnsafeMutableRawPointer?) {
|
||||
_ = api.realloc.unsafelyUnwrapped(pointer, 0)
|
||||
}
|
||||
|
||||
// MARK: - Logging
|
||||
|
||||
/// Logs a message to the console (device serial or simulator console).
|
||||
public static func log(_ message: String) {
|
||||
message.withPlaydateCString { cplaydate_log(Playdate.apiPointer, $0) }
|
||||
}
|
||||
|
||||
/// Stops execution and displays the message as a fatal error.
|
||||
public static func error(_ message: String) {
|
||||
message.withPlaydateCString { cplaydate_error(Playdate.apiPointer, $0) }
|
||||
}
|
||||
|
||||
// MARK: - Time
|
||||
|
||||
public static var language: Language { Language(api.getLanguage.unsafelyUnwrapped()) }
|
||||
|
||||
/// Milliseconds since the game launched. Wraps around after about 49 days.
|
||||
public static var currentTimeMilliseconds: UInt32 {
|
||||
UInt32(api.getCurrentTimeMilliseconds.unsafelyUnwrapped())
|
||||
}
|
||||
|
||||
/// Seconds (and sub-second milliseconds) since midnight 2000-01-01 UTC.
|
||||
public static var secondsSinceEpoch: (seconds: UInt32, milliseconds: UInt32) {
|
||||
var milliseconds: UInt32 = 0
|
||||
let seconds = withUnsafeMutablePointer(to: &milliseconds) {
|
||||
api.getSecondsSinceEpoch.unsafelyUnwrapped($0)
|
||||
}
|
||||
return (UInt32(seconds), milliseconds)
|
||||
}
|
||||
|
||||
/// High-resolution timer value, in seconds.
|
||||
public static var elapsedTime: Float { api.getElapsedTime.unsafelyUnwrapped() }
|
||||
|
||||
public static func resetElapsedTime() { api.resetElapsedTime.unsafelyUnwrapped() }
|
||||
|
||||
/// Offset from UTC of the user-set timezone, in seconds.
|
||||
public static var timezoneOffset: Int32 { api.getTimezoneOffset.unsafelyUnwrapped() }
|
||||
|
||||
public static var shouldDisplay24HourTime: Bool {
|
||||
api.shouldDisplay24HourTime.unsafelyUnwrapped() != 0
|
||||
}
|
||||
|
||||
public static func convertEpochToDateTime(_ epoch: UInt32) -> DateTime {
|
||||
var dateTime = PDDateTime()
|
||||
api.convertEpochToDateTime.unsafelyUnwrapped(epoch, &dateTime)
|
||||
return DateTime(dateTime)
|
||||
}
|
||||
|
||||
public static func convertDateTimeToEpoch(_ dateTime: DateTime) -> UInt32 {
|
||||
var cValue = dateTime.cValue
|
||||
return api.convertDateTimeToEpoch.unsafelyUnwrapped(&cValue)
|
||||
}
|
||||
|
||||
/// Blocks execution for the given number of milliseconds.
|
||||
public static func delay(milliseconds: UInt32) {
|
||||
api.delay.unsafelyUnwrapped(milliseconds)
|
||||
}
|
||||
|
||||
/// Requests the server time. The completion receives the time string or
|
||||
/// an error string. Only one request is tracked at a time; a second call
|
||||
/// before the first completes replaces the stored completion.
|
||||
public static func getServerTime(_ completion: @escaping (_ time: String?, _ error: String?) -> Void) {
|
||||
serverTimeCompletion = completion
|
||||
api.getServerTime.unsafelyUnwrapped { time, error in
|
||||
let completion = Playdate.System.serverTimeCompletion
|
||||
Playdate.System.serverTimeCompletion = nil
|
||||
completion?(String(playdateCString: time), String(playdateCString: error))
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var serverTimeCompletion: ((String?, String?) -> Void)?
|
||||
|
||||
// MARK: - Update loop
|
||||
|
||||
/// Sets the per-frame update callback. Return `true` to redraw the display.
|
||||
public static func setUpdateCallback(_ callback: @escaping () -> Bool) {
|
||||
updateCallback = callback
|
||||
api.setUpdateCallback.unsafelyUnwrapped({ _ in
|
||||
Playdate.System.updateCallback?() == true ? 1 : 0
|
||||
}, nil)
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var updateCallback: (() -> Bool)?
|
||||
|
||||
/// Draws the current frames-per-second value at the given point.
|
||||
public static func drawFPS(x: Int = 0, y: Int = 0) {
|
||||
api.drawFPS.unsafelyUnwrapped(Int32(x), Int32(y))
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
/// The current button state: held, pressed this frame, released this frame.
|
||||
public static var buttonState: (current: Buttons, pushed: Buttons, released: Buttons) {
|
||||
var current = PDButtons(0), pushed = PDButtons(0), released = PDButtons(0)
|
||||
api.getButtonState.unsafelyUnwrapped(¤t, &pushed, &released)
|
||||
return (Buttons(current), Buttons(pushed), Buttons(released))
|
||||
}
|
||||
|
||||
/// Installs a callback invoked for every button press/release. `queueSize`
|
||||
/// sets how many events are buffered between frames. The return value of
|
||||
/// the callback is reserved by the OS; return 0.
|
||||
public static func setButtonCallback(queueSize: Int = 5,
|
||||
_ callback: ((_ button: Buttons, _ isDown: Bool, _ when: UInt32) -> Int32)?) {
|
||||
buttonCallback = callback
|
||||
if callback != nil {
|
||||
api.setButtonCallback.unsafelyUnwrapped({ button, down, when, _ in
|
||||
Playdate.System.buttonCallback?(Buttons(button), down != 0, when) ?? 0
|
||||
}, nil, Int32(queueSize))
|
||||
} else {
|
||||
api.setButtonCallback.unsafelyUnwrapped(nil, nil, Int32(queueSize))
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var buttonCallback: ((Buttons, Bool, UInt32) -> Int32)?
|
||||
|
||||
public static func setPeripheralsEnabled(_ peripherals: Peripherals) {
|
||||
api.setPeripheralsEnabled.unsafelyUnwrapped(PDPeripherals(peripherals.rawValue))
|
||||
}
|
||||
|
||||
/// The most recent accelerometer reading, in g. Enable the accelerometer
|
||||
/// with `setPeripheralsEnabled(.accelerometer)` first.
|
||||
public static var accelerometer: (x: Float, y: Float, z: Float) {
|
||||
var x: Float = 0, y: Float = 0, z: Float = 0
|
||||
api.getAccelerometer.unsafelyUnwrapped(&x, &y, &z)
|
||||
return (x, y, z)
|
||||
}
|
||||
|
||||
/// Degrees the crank moved since the last frame.
|
||||
public static var crankChange: Float { api.getCrankChange.unsafelyUnwrapped() }
|
||||
|
||||
/// The crank position in degrees; 0 points along the +Y axis.
|
||||
public static var crankAngle: Float { api.getCrankAngle.unsafelyUnwrapped() }
|
||||
|
||||
public static var isCrankDocked: Bool { api.isCrankDocked.unsafelyUnwrapped() != 0 }
|
||||
|
||||
/// Disables or enables the crank dock/undock sounds. Returns the previous setting.
|
||||
@discardableResult
|
||||
public static func setCrankSoundsDisabled(_ disabled: Bool) -> Bool {
|
||||
api.setCrankSoundsDisabled.unsafelyUnwrapped(disabled ? 1 : 0) != 0
|
||||
}
|
||||
|
||||
/// Whether the user has the "flipped" system setting enabled.
|
||||
public static var isFlipped: Bool { api.getFlipped.unsafelyUnwrapped() != 0 }
|
||||
|
||||
public static func setAutoLockDisabled(_ disabled: Bool) {
|
||||
api.setAutoLockDisabled.unsafelyUnwrapped(disabled ? 1 : 0)
|
||||
}
|
||||
|
||||
/// Installs a callback invoked when a message is received on the serial port
|
||||
/// via `msg <text>`.
|
||||
public static func setSerialMessageCallback(_ callback: ((String) -> Void)?) {
|
||||
serialMessageCallback = callback
|
||||
if callback != nil {
|
||||
api.setSerialMessageCallback.unsafelyUnwrapped { data in
|
||||
guard let message = String(playdateCString: data) else { return }
|
||||
Playdate.System.serialMessageCallback?(message)
|
||||
}
|
||||
} else {
|
||||
api.setSerialMessageCallback.unsafelyUnwrapped(nil)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var serialMessageCallback: ((String) -> Void)?
|
||||
|
||||
// MARK: - System menu
|
||||
|
||||
/// An item added to the system menu. Keep no more than three items at once.
|
||||
public final class MenuItem {
|
||||
let pointer: OpaquePointer
|
||||
var onSelect: (MenuItem) -> Void
|
||||
/// Retains C strings passed to the OS for option titles.
|
||||
private var retainedOptionTitles: [UnsafeMutablePointer<CChar>] = []
|
||||
|
||||
fileprivate init?(pointer: OpaquePointer?,
|
||||
retainedOptionTitles: [UnsafeMutablePointer<CChar>] = [],
|
||||
onSelect: @escaping (MenuItem) -> Void) {
|
||||
guard let pointer else {
|
||||
for title in retainedOptionTitles { title.deallocate() }
|
||||
return nil
|
||||
}
|
||||
self.pointer = pointer
|
||||
self.retainedOptionTitles = retainedOptionTitles
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
/// The menu item's title.
|
||||
public var title: String {
|
||||
get {
|
||||
String(playdateCString: Playdate.api.system.pointee.getMenuItemTitle.unsafelyUnwrapped(pointer)) ?? ""
|
||||
}
|
||||
set {
|
||||
newValue.withPlaydateCString {
|
||||
Playdate.api.system.pointee.setMenuItemTitle.unsafelyUnwrapped(pointer, $0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// For checkmark items this is 0 or 1; for option items it is the
|
||||
/// index of the selected option.
|
||||
public var value: Int {
|
||||
get { Int(Playdate.api.system.pointee.getMenuItemValue.unsafelyUnwrapped(pointer)) }
|
||||
set { Playdate.api.system.pointee.setMenuItemValue.unsafelyUnwrapped(pointer, Int32(newValue)) }
|
||||
}
|
||||
|
||||
/// Convenience view of `value` for checkmark items.
|
||||
public var isChecked: Bool {
|
||||
get { value != 0 }
|
||||
set { value = newValue ? 1 : 0 }
|
||||
}
|
||||
|
||||
fileprivate func deallocateRetainedTitles() {
|
||||
for title in retainedOptionTitles { title.deallocate() }
|
||||
retainedOptionTitles = []
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static var liveMenuItems: [MenuItem] = []
|
||||
|
||||
private static let menuItemTrampoline: @convention(c) (UnsafeMutableRawPointer?) -> Void = { userdata in
|
||||
guard let userdata else { return }
|
||||
let item = Unmanaged<MenuItem>.fromOpaque(userdata).takeUnretainedValue()
|
||||
item.onSelect(item)
|
||||
}
|
||||
|
||||
/// Adds a plain menu item to the system menu.
|
||||
@discardableResult
|
||||
public static func addMenuItem(title: String, onSelect: @escaping (MenuItem) -> Void) -> MenuItem? {
|
||||
var item: MenuItem?
|
||||
title.withPlaydateCString { cTitle in
|
||||
let pointer = api.addMenuItem.unsafelyUnwrapped(cTitle, menuItemTrampoline, nil)
|
||||
item = MenuItem(pointer: pointer, onSelect: onSelect)
|
||||
}
|
||||
return registered(item)
|
||||
}
|
||||
|
||||
/// Adds a menu item with a checkbox.
|
||||
@discardableResult
|
||||
public static func addCheckmarkMenuItem(title: String, isChecked: Bool = false,
|
||||
onSelect: @escaping (MenuItem) -> Void) -> MenuItem? {
|
||||
var item: MenuItem?
|
||||
title.withPlaydateCString { cTitle in
|
||||
let pointer = api.addCheckmarkMenuItem.unsafelyUnwrapped(
|
||||
cTitle, isChecked ? 1 : 0, menuItemTrampoline, nil)
|
||||
item = MenuItem(pointer: pointer, onSelect: onSelect)
|
||||
}
|
||||
return registered(item)
|
||||
}
|
||||
|
||||
/// Adds a menu item that cycles through the given options.
|
||||
@discardableResult
|
||||
public static func addOptionsMenuItem(title: String, options: [String],
|
||||
onSelect: @escaping (MenuItem) -> Void) -> MenuItem? {
|
||||
// The OS keeps the option title pointers, so copy and retain them for
|
||||
// the lifetime of the menu item.
|
||||
let copies = options.map { $0.copiedPlaydateCString() }
|
||||
var cOptions: [UnsafePointer<CChar>?] = copies.map { UnsafePointer($0) }
|
||||
var item: MenuItem?
|
||||
title.withPlaydateCString { cTitle in
|
||||
cOptions.withUnsafeMutableBufferPointer { buffer in
|
||||
let pointer = api.addOptionsMenuItem.unsafelyUnwrapped(
|
||||
cTitle, buffer.baseAddress, Int32(options.count), menuItemTrampoline, nil)
|
||||
item = MenuItem(pointer: pointer, retainedOptionTitles: copies, onSelect: onSelect)
|
||||
}
|
||||
}
|
||||
return registered(item)
|
||||
}
|
||||
|
||||
/// Registers the wrapper as the item's userdata and keeps it alive.
|
||||
private static func registered(_ item: MenuItem?) -> MenuItem? {
|
||||
guard let item else { return nil }
|
||||
api.setMenuItemUserdata.unsafelyUnwrapped(
|
||||
item.pointer, Unmanaged.passUnretained(item).toOpaque())
|
||||
liveMenuItems.append(item)
|
||||
return item
|
||||
}
|
||||
|
||||
public static func removeMenuItem(_ item: MenuItem) {
|
||||
api.removeMenuItem.unsafelyUnwrapped(item.pointer)
|
||||
item.deallocateRetainedTitles()
|
||||
liveMenuItems.removeAll { $0 === item }
|
||||
}
|
||||
|
||||
public static func removeAllMenuItems() {
|
||||
api.removeAllMenuItems.unsafelyUnwrapped()
|
||||
for item in liveMenuItems { item.deallocateRetainedTitles() }
|
||||
liveMenuItems = []
|
||||
}
|
||||
|
||||
/// Sets a custom image for the pause menu, optionally shifted left by
|
||||
/// `xOffset` (0...200).
|
||||
public static func setMenuImage(_ bitmap: Playdate.Graphics.Bitmap?, xOffset: Int = 0) {
|
||||
api.setMenuImage.unsafelyUnwrapped(bitmap?.pointer, Int32(xOffset))
|
||||
}
|
||||
|
||||
// MARK: - Device state
|
||||
|
||||
/// Whether the user has enabled the "reduce flashing" accessibility setting.
|
||||
public static var reduceFlashing: Bool { api.getReduceFlashing.unsafelyUnwrapped() != 0 }
|
||||
|
||||
/// Battery charge, 0...100.
|
||||
public static var batteryPercentage: Float { api.getBatteryPercentage.unsafelyUnwrapped() }
|
||||
|
||||
public static var batteryVoltage: Float { api.getBatteryVoltage.unsafelyUnwrapped() }
|
||||
|
||||
/// Flushes the CPU instruction cache after loading code at runtime.
|
||||
public static func clearICache() { api.clearICache.unsafelyUnwrapped() }
|
||||
|
||||
/// Quits the current game and restarts it with the given launch arguments.
|
||||
public static func restartGame(launchArguments: String? = nil) {
|
||||
if let launchArguments {
|
||||
launchArguments.withPlaydateCString { api.restartGame.unsafelyUnwrapped($0) }
|
||||
} else {
|
||||
api.restartGame.unsafelyUnwrapped(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// The arguments the game was launched with, and the path of the pdx.
|
||||
public static var launchArguments: (arguments: String?, path: String?) {
|
||||
var path: UnsafePointer<CChar>?
|
||||
let arguments = api.getLaunchArgs.unsafelyUnwrapped(&path)
|
||||
return (String(playdateCString: arguments), String(playdateCString: path))
|
||||
}
|
||||
|
||||
/// Sends data over the mirror connection. Returns `false` if mirroring is
|
||||
/// not active or the send fails.
|
||||
@discardableResult
|
||||
public static func sendMirrorData(command: UInt8, data: UnsafeMutableRawBufferPointer) -> Bool {
|
||||
api.sendMirrorData.unsafelyUnwrapped(command, data.baseAddress, Int32(data.count))
|
||||
}
|
||||
|
||||
/// OS, language, and pdx version information.
|
||||
public static var info: Info {
|
||||
let info = api.getSystemInfo.unsafelyUnwrapped().unsafelyUnwrapped.pointee
|
||||
return Info(osVersion: info.osversion,
|
||||
language: Language(info.language),
|
||||
pdxVersion: info.pdxversion)
|
||||
}
|
||||
|
||||
/// Looks up a localized string by key from the game's strings files.
|
||||
public static func localizedText(forKey key: String, language: Language = .system) -> String? {
|
||||
key.withPlaydateCString { cKey in
|
||||
guard let cString = api.getLocalizedText.unsafelyUnwrapped(cKey, language.cValue) else {
|
||||
return nil
|
||||
}
|
||||
let text = String(playdateCString: cString)
|
||||
systemFree(cString)
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/// The system volume, 0...1.
|
||||
public static var volume: Float { api.getVolume.unsafelyUnwrapped() }
|
||||
|
||||
public static var powerStatus: PowerStatus {
|
||||
PowerStatus(rawValue: api.getPowerStatus.unsafelyUnwrapped().rawValue)
|
||||
}
|
||||
|
||||
/// Quits the game and returns to the launcher.
|
||||
public static func exitToLauncher() { api.exitToLauncher.unsafelyUnwrapped() }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Testing
|
||||
@testable import PlayDate
|
||||
|
||||
// Most of the binding requires a running Playdate (the API struct is handed
|
||||
// to the game at launch), so these tests cover the pure value types and the
|
||||
// inline C helpers that work without it.
|
||||
|
||||
@Test func buttonsOptionSetMatchesCMasks() {
|
||||
#expect(Playdate.System.Buttons.left.rawValue == 1 << 0)
|
||||
#expect(Playdate.System.Buttons.right.rawValue == 1 << 1)
|
||||
#expect(Playdate.System.Buttons.up.rawValue == 1 << 2)
|
||||
#expect(Playdate.System.Buttons.down.rawValue == 1 << 3)
|
||||
#expect(Playdate.System.Buttons.b.rawValue == 1 << 4)
|
||||
#expect(Playdate.System.Buttons.a.rawValue == 1 << 5)
|
||||
|
||||
let combined: Playdate.System.Buttons = [.a, .up]
|
||||
#expect(combined.contains(.a))
|
||||
#expect(!combined.contains(.b))
|
||||
}
|
||||
|
||||
@Test func graphicsRectConvertsBetweenOriginSizeAndEdges() {
|
||||
let rect = Playdate.Graphics.Rect(x: 10, y: 20, width: 30, height: 40)
|
||||
#expect(rect.left == 10)
|
||||
#expect(rect.right == 40)
|
||||
#expect(rect.top == 20)
|
||||
#expect(rect.bottom == 60)
|
||||
|
||||
let translated = rect.translated(dx: 5, dy: -5)
|
||||
#expect(translated.left == 15)
|
||||
#expect(translated.top == 15)
|
||||
|
||||
let roundTripped = Playdate.Graphics.Rect(rect.cValue)
|
||||
#expect(roundTripped.left == rect.left && roundTripped.bottom == rect.bottom)
|
||||
}
|
||||
|
||||
@Test func spriteRectRoundTripsThroughC() {
|
||||
let rect = Playdate.Rect(x: 1.5, y: 2.5, width: 3, height: 4)
|
||||
let roundTripped = Playdate.Rect(rect.cValue)
|
||||
#expect(roundTripped.x == 1.5)
|
||||
#expect(roundTripped.y == 2.5)
|
||||
#expect(roundTripped.width == 3)
|
||||
#expect(roundTripped.height == 4)
|
||||
}
|
||||
|
||||
@Test func soundFormatPropertiesMatchCMacros() {
|
||||
#expect(!Playdate.Sound.Format.mono8bit.isStereo)
|
||||
#expect(Playdate.Sound.Format.stereo16bit.isStereo)
|
||||
#expect(Playdate.Sound.Format.mono16bit.is16bit)
|
||||
#expect(!Playdate.Sound.Format.monoADPCM.is16bit)
|
||||
|
||||
#expect(Playdate.Sound.Format.mono8bit.bytesPerFrame == 1)
|
||||
#expect(Playdate.Sound.Format.stereo8bit.bytesPerFrame == 2)
|
||||
#expect(Playdate.Sound.Format.mono16bit.bytesPerFrame == 2)
|
||||
#expect(Playdate.Sound.Format.stereo16bit.bytesPerFrame == 4)
|
||||
}
|
||||
|
||||
@Test func midiNoteFrequencyConversionRoundTrips() {
|
||||
// A4 (MIDI 69) is 440 Hz.
|
||||
let a4 = Playdate.Sound.frequency(forNote: 69)
|
||||
#expect(abs(a4 - 440) < 0.01)
|
||||
|
||||
let note = Playdate.Sound.note(forFrequency: 440)
|
||||
#expect(abs(note - 69) < 0.001)
|
||||
}
|
||||
|
||||
@Test func cStringHelpersRoundTripUTF8() {
|
||||
let original = "héllo, wörld"
|
||||
let copy = original.copiedPlaydateCString()
|
||||
defer { copy.deallocate() }
|
||||
#expect(String(playdateCString: copy) == original)
|
||||
|
||||
let viaClosure = original.withPlaydateCString { String(playdateCString: $0) }
|
||||
#expect(viaClosure == original)
|
||||
}
|
||||
|
||||
@Test func dateTimeMirrorsCStruct() {
|
||||
let dateTime = Playdate.System.DateTime(year: 2026, month: 7, day: 24, weekday: 5,
|
||||
hour: 12, minute: 34, second: 56)
|
||||
let roundTripped = Playdate.System.DateTime(dateTime.cValue)
|
||||
#expect(roundTripped.year == 2026)
|
||||
#expect(roundTripped.month == 7)
|
||||
#expect(roundTripped.day == 24)
|
||||
#expect(roundTripped.weekday == 5)
|
||||
#expect(roundTripped.hour == 12)
|
||||
#expect(roundTripped.minute == 34)
|
||||
#expect(roundTripped.second == 56)
|
||||
}
|
||||
Reference in New Issue
Block a user