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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CoolZombie/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions CoolZombie/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// swift-tools-version: 6.0
// CoolZombie — AI character locomotion demo for Untold Engine.
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import PackageDescription

let package = Package(
name: "CoolZombie",
platforms: [
.macOS(.v14),
.iOS(.v17),
.visionOS(.v2),
],
dependencies: [
.package(url: "https://github.com/miolabs/UntoldEngine.git", branch: "develop"),
],
targets: [
.executableTarget(
name: "CoolZombie",
dependencies: [
.product(name: "UntoldEngine", package: "UntoldEngine"),
],
resources: [
.copy("Resources/Models"),
.copy("Resources/Animations"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
]
),
]
)
35 changes: 35 additions & 0 deletions CoolZombie/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# CoolZombie

AI character locomotion demo for [Untold Engine](https://github.com/untoldengine/UntoldEngine) — **no animation state machine**.

A wandering target orbits the arena and the character chases it. Every frame the AI states a *goal* (desired velocity and facing, straight from steering); the engine's animation stack does the rest:

- **Motion matching** searches the loaded clips for the frame that best matches the current pose and predicted trajectory — nobody calls `changeAnimation`.
- **Inertialized transitions** smooth every clip jump.
- **Root motion** moves the entity: the clips' own travel is authoritative, so there is no foot sliding from mismatched speeds.
- **Foot IK** plants the feet on the ground.

Far from the target the character runs, closing in it walks, arrived it idles — all emergent from one `setMotionMatchingGoal` call per frame.

## Run

```bash
swift run CoolZombie
```

macOS 14+. The package pins the engine to the `feature/animation_motion_matching` branch until the animation stack merges into `develop`.

## Assets

Placeholder assets while real motion data lands:

- `redplayer` rig + `idle` clip from the engine's test resources.
- `run_forward` / `walk_forward` are generated from the engine's in-place `running` clip by injecting linear root travel (2.35 m/s and 0.94 m/s) — real traveling locomotion for the motion database, synthetic until mocap replaces it.

The demo is written so assets swap without code changes: drop in new `.untold` clips (e.g. retargeted [ChingMu MotionDecode](https://huggingface.co/datasets/CMRobot/MotionDecode) captures), list them in `configureZombieAnimation`, and the motion database rebuilds from whatever is loaded.

Planned motion data attribution: *Motion data: ChingMu MotionDecode Data Openness Program* (non-commercial use with attribution, per its access terms).

## License

MPL-2.0, matching the engine. This demo is non-commercial.
128 changes: 128 additions & 0 deletions CoolZombie/Sources/CoolZombie/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//
// AppDelegate.swift
// CoolZombie
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

#if os(macOS)
import AppKit
import SwiftUI
import UntoldEngine

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private enum Constants {
static let windowSize = NSSize(width: 1280, height: 720)
static let minimumWindowSize = NSSize(width: 800, height: 600)
}

private var window: NSWindow!
private var renderer: UntoldRenderer!
private var zombieScene: ZombieScene!

func applicationDidFinishLaunching(_: Notification) {
setupWindow()
setupRendererAndScene()
presentSceneView()
}

func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
true
}

private func setupWindow() {
window = NSWindow(
contentRect: NSRect(origin: .zero, size: Constants.windowSize),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
window.title = "CoolZombie — Motion Matching Demo"
window.minSize = Constants.minimumWindowSize
window.center()
}

private func setupRendererAndScene() {
guard let renderer = UntoldRenderer.create() else {
print("Failed to initialize UntoldRenderer.")
NSApp.terminate(nil)
return
}

self.renderer = renderer
zombieScene = ZombieScene()

renderer.setupCallbacks(
gameUpdate: { [weak self] deltaTime in
self?.zombieScene.update(deltaTime: deltaTime)
},
handleInput: {}
)
}

private func presentSceneView() {
guard let renderer else { return }

let hostingView = NSHostingView(rootView: CoolZombieView(renderer: renderer))
window.contentView = hostingView
window.makeKeyAndOrderFront(nil)
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
}

private struct CoolZombieView: View {
let renderer: UntoldRenderer
@State private var isPlaying = false

var body: some View {
ZStack {
SceneView(renderer: renderer)

if isPlaying {
// Minimal overlay while running so recordings stay clean.
VStack {
Spacer()
HStack {
Spacer()
playPauseButton(systemName: "pause.fill")
.padding(16)
}
}
} else {
VStack(spacing: 16) {
Text("CoolZombie")
.font(.title.bold())
Text("Motion matching: the character picks clips by itself — no state machine.")
.font(.caption)
.foregroundStyle(.secondary)
playPauseButton(systemName: "play.fill")
}
.padding(28)
.background(.black.opacity(0.55))
.clipShape(RoundedRectangle(cornerRadius: 14))
.foregroundStyle(.white)
}
}
}

private func playPauseButton(systemName: String) -> some View {
Button {
isPlaying.toggle()
gameMode = isPlaying
} label: {
Image(systemName: systemName)
.font(.title2)
.frame(width: 44, height: 44)
.background(Circle().fill(.black.opacity(0.45)))
.foregroundStyle(.white.opacity(0.9))
}
.buttonStyle(.plain)
.keyboardShortcut(.space, modifiers: [])
}
}
#endif
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Git LFS file not shown
Loading