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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ http://www.haroldserrano.com

The fastest way to experience Untold Engine is to run the demo project.

> **Recommendation:** Use the latest stable release instead of the `develop`
> branch. The `develop` branch is the bleeding-edge version of Untold Engine and
> is updated frequently, so it may contain unstable changes or regressions.

Clone the repository and launch the demo:

```bash
Expand Down
3 changes: 2 additions & 1 deletion Sources/DemoGame/DemoHUD.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@

sectionLabel("CONTROLS")
controlHint("WASD / QE", "Translate")
controlHint("Right-click (shift) drag", "Orbit/Rotate")
controlHint("Right-drag (+ Shift)", "Orbit / Yaw")
controlHint("Two-finger swipe (+ Shift)", "Orbit / Yaw")

Divider()

Expand Down
20 changes: 20 additions & 0 deletions Sources/DemoGame/GameScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
private var loadedContent: LoadedContent = .none
private var cameraBehavior: CameraBehavior = .flyOrbit
private var wasRightMousePressed: Bool = false
private var wasScrolling: Bool = false

init() {
InputSystem.shared.registerKeyboardEvents()
Expand Down Expand Up @@ -333,6 +334,25 @@
}
}
wasRightMousePressed = input.keyState.rightMousePressed

// Two-finger trackpad drag fires scrollWheel events — support it as an
// alternative orbit (and shift+scroll for yaw) so users don't need
// right-click drag. resetOrbitTarget fires only on the first scroll
// frame, matching the right-mouse-press behaviour above.
let scroll = input.scrollDelta
let isScrolling = (scroll.x != 0 || scroll.y != 0) && !input.keyState.rightMousePressed
if isScrolling {
if !wasScrolling {
resetOrbitTarget(entityId: camera)
}
if input.keyState.shiftPressed {
rotateCamera(entityId: camera, pitch: 0, yaw: scroll.x, sensitivity: -0.01)
} else {
orbitCameraAround(entityId: camera, uDelta: scroll)
}
}
wasScrolling = isScrolling
input.scrollDelta = .zero
}

private func resetOrbitTarget(entityId: EntityID) {
Expand Down
188 changes: 0 additions & 188 deletions Sources/UntoldEngine/BuildSystem/BuildTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,53 +182,6 @@ import Foundation
// Add your custom input handling here
}

// MARK: - Scene Loading

/// Load a `.untoldscene` from `GameData/Scenes`.
///
/// The scene file is JSON-backed and contains project-relative references to
/// `.untold`, stream-model manifests, animations, HDR files, and other scene assets.
/// `assetBasePath` must already point at `GameData`.
func loadUntoldScene(
named sceneName: String,
meshLoadingMode: MeshLoadingMode = .asyncDefault,
completion: ((Bool) -> Void)? = nil
) {
guard let gameDataURL = assetBasePath else {
Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.")
completion?(false)
return
}

let sceneURL = gameDataURL
.appendingPathComponent("Scenes", isDirectory: true)
.appendingPathComponent(sceneName)
.appendingPathExtension("untoldscene")

guard FileManager.default.fileExists(atPath: sceneURL.path) else {
Logger.log(message: "❌ Scene file not found: \\(sceneURL.path)")
completion?(false)
return
}

do {
let data = try Data(contentsOf: sceneURL)
let sceneData = try JSONDecoder().decode(SceneData.self, from: data)

// Ensure all relative asset references resolve against the bundled GameData.
assetBasePath = gameDataURL

Logger.log(message: "📄 Loading Untold scene: \\(sceneURL.lastPathComponent)")

deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) {
Logger.log(message: "✅ Finished loading scene: \\(sceneURL.lastPathComponent)")
completion?(true)
}
} catch {
Logger.log(message: "❌ Failed to load scene \\(sceneURL.lastPathComponent): \\(error.localizedDescription)")
completion?(false)
}
}
}
"""

Expand Down Expand Up @@ -673,53 +626,6 @@ import Foundation
// Add your custom input handling here
}

// MARK: - Scene Loading

/// Load a `.untoldscene` from `GameData/Scenes`.
///
/// The scene file is JSON-backed and contains project-relative references to
/// `.untold`, stream-model manifests, animations, HDR files, and other scene assets.
/// `assetBasePath` must already point at `GameData`.
func loadUntoldScene(
named sceneName: String,
meshLoadingMode: MeshLoadingMode = .asyncDefault,
completion: ((Bool) -> Void)? = nil
) {
guard let gameDataURL = assetBasePath else {
Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.")
completion?(false)
return
}

let sceneURL = gameDataURL
.appendingPathComponent("Scenes", isDirectory: true)
.appendingPathComponent(sceneName)
.appendingPathExtension("untoldscene")

guard FileManager.default.fileExists(atPath: sceneURL.path) else {
Logger.log(message: "❌ Scene file not found: \\(sceneURL.path)")
completion?(false)
return
}

do {
let data = try Data(contentsOf: sceneURL)
let sceneData = try JSONDecoder().decode(SceneData.self, from: data)

// Ensure all relative asset references resolve against the bundled GameData.
assetBasePath = gameDataURL

Logger.log(message: "📄 Loading Untold scene: \\(sceneURL.lastPathComponent)")

deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) {
Logger.log(message: "✅ Finished loading scene: \\(sceneURL.lastPathComponent)")
completion?(true)
}
} catch {
Logger.log(message: "❌ Failed to load scene \\(sceneURL.lastPathComponent): \\(error.localizedDescription)")
completion?(false)
}
}
}
"""

Expand Down Expand Up @@ -860,53 +766,6 @@ import Foundation
// Add your custom input handling here
}

// MARK: - Scene Loading

/// Load a `.untoldscene` from `GameData/Scenes`.
///
/// The scene file is JSON-backed and contains project-relative references to
/// `.untold`, stream-model manifests, animations, HDR files, and other scene assets.
/// `assetBasePath` must already point at `GameData`.
func loadUntoldScene(
named sceneName: String,
meshLoadingMode: MeshLoadingMode = .asyncDefault,
completion: ((Bool) -> Void)? = nil
) {
guard let gameDataURL = assetBasePath else {
Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.")
completion?(false)
return
}

let sceneURL = gameDataURL
.appendingPathComponent("Scenes", isDirectory: true)
.appendingPathComponent(sceneName)
.appendingPathExtension("untoldscene")

guard FileManager.default.fileExists(atPath: sceneURL.path) else {
Logger.log(message: "❌ Scene file not found: \\(sceneURL.path)")
completion?(false)
return
}

do {
let data = try Data(contentsOf: sceneURL)
let sceneData = try JSONDecoder().decode(SceneData.self, from: data)

// Ensure all relative asset references resolve against the bundled GameData.
assetBasePath = gameDataURL

Logger.log(message: "📄 Loading Untold scene: \\(sceneURL.lastPathComponent)")

deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) {
Logger.log(message: "✅ Finished loading scene: \\(sceneURL.lastPathComponent)")
completion?(true)
}
} catch {
Logger.log(message: "❌ Failed to load scene \\(sceneURL.lastPathComponent): \\(error.localizedDescription)")
completion?(false)
}
}
}
"""

Expand Down Expand Up @@ -1251,53 +1110,6 @@ import Foundation
// Add your custom input handling here
}

// MARK: - Scene Loading

/// Load a `.untoldscene` from `GameData/Scenes`.
///
/// The scene file is JSON-backed and contains project-relative references to
/// `.untold`, stream-model manifests, animations, HDR files, and other scene assets.
/// `assetBasePath` must already point at `GameData`.
func loadUntoldScene(
named sceneName: String,
meshLoadingMode: MeshLoadingMode = .asyncDefault,
completion: ((Bool) -> Void)? = nil
) {
guard let gameDataURL = assetBasePath else {
Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.")
completion?(false)
return
}

let sceneURL = gameDataURL
.appendingPathComponent("Scenes", isDirectory: true)
.appendingPathComponent(sceneName)
.appendingPathExtension("untoldscene")

guard FileManager.default.fileExists(atPath: sceneURL.path) else {
Logger.log(message: "❌ Scene file not found: \\(sceneURL.path)")
completion?(false)
return
}

do {
let data = try Data(contentsOf: sceneURL)
let sceneData = try JSONDecoder().decode(SceneData.self, from: data)

// Ensure all relative asset references resolve against the bundled GameData.
assetBasePath = gameDataURL

Logger.log(message: "📄 Loading Untold scene: \\(sceneURL.lastPathComponent)")

deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) {
Logger.log(message: "✅ Finished loading scene: \\(sceneURL.lastPathComponent)")
completion?(true)
}
} catch {
Logger.log(message: "❌ Failed to load scene \\(sceneURL.lastPathComponent): \\(error.localizedDescription)")
completion?(false)
}
}
}

@MainActor
Expand Down
82 changes: 79 additions & 3 deletions Sources/UntoldEngine/Scenes/SceneSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ enum SceneAssetKind: String, Codable {

struct SceneAssetReference: Codable {
var kind: SceneAssetKind
/// Project-relative path from the asset base folder, such as
/// "Models/Robot/Robot.untold" or "StreamModels/City/City.json".
/// Asset path. For local assets this is a project-relative path from the asset base folder
/// (e.g. "Models/Robot/Robot.untold"). For remote stream models this is the full
/// https:// URL string (e.g. "https://cdn.example.com/dungeon/dungeon.json").
var path: String
var displayName: String? = nil
}
Expand Down Expand Up @@ -266,6 +267,11 @@ private func sceneAssetReference(kind: SceneAssetKind, url: URL, displayName: St
return SceneAssetReference(kind: .procedural, path: url.path, displayName: displayName)
}

// Remote URLs are stored as their full https:// string so they survive round-trips.
if url.scheme?.lowercased() == "https" {
return SceneAssetReference(kind: kind, path: url.absoluteString, displayName: displayName)
}

guard let relativePath = projectRelativeAssetPath(for: url) else {
Logger.logWarning(message: "[SceneSerializer] Skipping non-project asset reference: \(url.path)")
return nil
Expand All @@ -279,6 +285,11 @@ private func resolvedSceneAssetURL(_ reference: SceneAssetReference) -> URL? {
return URL(fileURLWithPath: reference.path)
}

// Remote URLs were stored as full https:// strings — return them directly.
if reference.path.hasPrefix("https://") {
return URL(string: reference.path)
}

guard let basePath = assetBasePath else {
Logger.logWarning(message: "[SceneSerializer] Cannot resolve asset '\(reference.path)' because assetBasePath is not set")
return nil
Expand Down Expand Up @@ -899,7 +910,7 @@ public func loadGameScene(from url: URL) -> SceneData? {
}
}

public enum MeshLoadingMode {
public enum MeshLoadingMode: Sendable {
case asyncDefault
case sync
}
Expand Down Expand Up @@ -1568,6 +1579,71 @@ public func deserializeScene(
loadTracker.finishRegistration()
}

// MARK: - Scene Loading

private let untoldSceneFileExtension = "untoldscene"

private func normalizedUntoldSceneBaseName(_ sceneName: String) -> String? {
let trimmedName = sceneName.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmedName.isEmpty == false else {
return nil
}

let sceneURL = URL(fileURLWithPath: trimmedName)
let fileExtension = sceneURL.pathExtension.lowercased()
guard fileExtension.isEmpty || fileExtension == untoldSceneFileExtension else {
return nil
}

return sceneURL.deletingPathExtension().lastPathComponent
}

public func loadUntoldScene(
named sceneName: String,
meshLoadingMode: MeshLoadingMode = .asyncDefault,
completion: ((Bool) -> Void)? = nil
) {
guard let sceneBaseName = normalizedUntoldSceneBaseName(sceneName) else {
Logger.log(message: "❌ loadUntoldScene(named:) only accepts .\(untoldSceneFileExtension) scene files or names without an extension. Received: \(sceneName)")
completion?(false)
return
}

guard let gameDataURL = assetBasePath else {
Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.")
completion?(false)
return
}

let sceneURL = gameDataURL
.appendingPathComponent("Scenes", isDirectory: true)
.appendingPathComponent(sceneBaseName)
.appendingPathExtension(untoldSceneFileExtension)

guard FileManager.default.fileExists(atPath: sceneURL.path) else {
Logger.log(message: "❌ Scene file not found: \(sceneURL.path)")
completion?(false)
return
}

do {
let data = try Data(contentsOf: sceneURL)
let sceneData = try JSONDecoder().decode(SceneData.self, from: data)

assetBasePath = gameDataURL

Logger.log(message: "📄 Loading Untold scene: \(sceneURL.lastPathComponent)")

deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) {
Logger.log(message: "✅ Finished loading scene: \(sceneURL.lastPathComponent)")
completion?(true)
}
} catch {
Logger.log(message: "❌ Failed to load scene \(sceneURL.lastPathComponent): \(error.localizedDescription)")
completion?(false)
}
}

/// Notification posted when asset instance has finished loading and overrides have been applied
public extension Notification.Name {
static let assetInstanceDidLoad = Notification.Name("assetInstanceDidLoad")
Expand Down
Loading
Loading