Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6822156cd | ||
|
|
5eb1832907 | ||
|
|
ad23798da5 | ||
|
|
132ddeff70 | ||
|
|
c8d45b6ff5 | ||
|
|
962c479933 | ||
|
|
72d9a21782 | ||
|
|
cc2a428a7d | ||
|
|
824aa2e9a0 | ||
|
|
c2736c158f | ||
|
|
d4b5b88be1 | ||
|
|
78e86970d7 | ||
|
|
a6ebf841d0 | ||
|
|
f5944907cc | ||
|
|
d436311f69 | ||
|
|
6d7adba259 | ||
|
|
1e28291320 | ||
|
|
49c9e97203 | ||
|
|
a1eb467600 | ||
|
|
a25db02077 | ||
|
|
239a449fe2 | ||
|
|
b6cc16531d | ||
|
|
70be14646a | ||
|
|
70534c271c | ||
|
|
a15d2a1fe3 | ||
|
|
6e8ebcd90f | ||
|
|
adcdd5fbad | ||
|
|
0ec28ad86b | ||
|
|
ba9d35443f | ||
|
|
0fa39a3e17 | ||
|
|
fa6f37b7f5 | ||
|
|
6151233e05 | ||
|
|
53ed8c6476 | ||
|
|
ff896506f5 | ||
|
|
8a842b19a6 | ||
|
|
f22242e5aa | ||
|
|
2e10968d48 | ||
|
|
2fcbaa6c12 | ||
|
|
67e2d77823 | ||
|
|
55871f666b | ||
|
|
ef59ae297f | ||
|
|
7b83e82c6e | ||
|
|
515f4ca1b3 | ||
|
|
3d8e3b2fd3 | ||
|
|
c6a24ba393 | ||
|
|
7dca06e4b1 | ||
|
|
6dfdb824e2 | ||
|
|
de49a53172 | ||
|
|
1030dc9a43 | ||
|
|
3d25734dfa | ||
|
|
12f628c469 | ||
|
|
1529213434 | ||
|
|
f8bb865920 | ||
|
|
f5fb32565e | ||
|
|
f1a8f538d3 | ||
|
|
963b01a011 | ||
|
|
6aec9dc544 | ||
|
|
70a0111deb | ||
|
|
0da2549d91 | ||
|
|
810984ae7a | ||
|
|
62c8129364 | ||
|
|
674af03a65 | ||
|
|
6b0d006467 | ||
|
|
ff7e95afe4 | ||
|
|
67b16d3da5 | ||
|
|
56ca1c638e |
@@ -0,0 +1,4 @@
|
||||
# Agents working on Aego
|
||||
|
||||
- Binaries go to `.cache/bin` only. Never run `go build` without `-o` in the repository root, and build test binaries only with `go test -c -o .cache/bin/<name>.test`. `make clean` empties `.cache/bin`; `make ci` fails when the root holds a binary or a `*.test` file (`tools/ci-clean-root.sh`).
|
||||
- Run the full `make ci` before every push. Between pushes, run the checks that cover the change: the tests of the changed packages, `make lint`, `make depcheck`, the docs check and the golden tests when the UI changed.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Claude configuration
|
||||
|
||||
This directory contains project-specific guidance and local configuration for Claude Code. The guidance records repository hygiene and verification expectations for agents working on Aego.
|
||||
@@ -5,3 +5,4 @@ site
|
||||
exports
|
||||
**/.aego
|
||||
*.test
|
||||
.claude
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# CI workflows
|
||||
|
||||
The workflows in this directory automate checks, builds, tests, and release publishing for Aego. Optional platform-runner jobs cover Windows and macOS alongside the main Linux checks.
|
||||
@@ -9,15 +9,18 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
|
||||
LINUX_PKGS: >-
|
||||
build-essential cmake ninja-build pkg-config
|
||||
build-essential cmake ninja-build pkg-config openssl
|
||||
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
|
||||
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
|
||||
libwayland-dev wayland-protocols libdecor-0-dev
|
||||
libasound2-dev libpulse-dev libdrm-dev libgbm-dev libudev-dev libdbus-1-dev libibus-1.0-dev
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Native runs on Windows and macOS machines; the Windows build itself is cross-compiled on Linux in ci.yml (D301).
|
||||
smoke:
|
||||
name: ${{ matrix.goos }} ${{ matrix.goarch }} smoke
|
||||
if: vars.PLATFORM_RUNNERS == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -39,7 +42,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
- name: linux packages
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
|
||||
- name: msys2
|
||||
if: runner.os == 'Windows'
|
||||
id: msys2
|
||||
|
||||
@@ -3,14 +3,16 @@ name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
|
||||
LINUX_PKGS: >-
|
||||
build-essential cmake ninja-build pkg-config
|
||||
build-essential cmake ninja-build pkg-config openssl
|
||||
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
|
||||
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
|
||||
libwayland-dev wayland-protocols libdecor-0-dev
|
||||
@@ -25,7 +27,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: system packages
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
|
||||
- uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
@@ -36,6 +38,10 @@ jobs:
|
||||
- run: go vet ./platform/sdl
|
||||
- run: make lint
|
||||
- run: make depcheck
|
||||
- name: packaging tools
|
||||
run: sudo apt-get install -y nsis squashfs-tools
|
||||
- name: installer dry run
|
||||
run: make nsis-check
|
||||
|
||||
build:
|
||||
strategy:
|
||||
@@ -56,7 +62,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
- name: linux packages
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
|
||||
- name: msys2
|
||||
if: runner.os == 'Windows'
|
||||
id: msys2
|
||||
@@ -111,4 +117,42 @@ jobs:
|
||||
- name: example project
|
||||
run: |
|
||||
/tmp/aego check examples/native-rotator
|
||||
/tmp/aego run examples/native-rotator --headless --frames 60
|
||||
/tmp/aego run examples/native-rotator --headless --frames 60
|
||||
|
||||
release:
|
||||
needs: [checks, build]
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the key that signs release.json and the public half the binaries trust; without them no release.json is published (D362)
|
||||
AEGO_RELEASE_KEY: ${{ secrets.AEGO_RELEASE_KEY }}
|
||||
AEGO_UPDATE_KEY: ${{ vars.AEGO_UPDATE_KEY || secrets.AEGO_UPDATE_KEY }}
|
||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: linux packages
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
|
||||
- uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .cache/native
|
||||
key: native-linux_amd64-${{ hashFiles('third_party/sdl/version.txt', 'tools/bootstrap-native.sh') }}
|
||||
- run: bash tools/bootstrap-native.sh
|
||||
- name: windows tools
|
||||
run: sudo apt-get install -y mingw-w64 nsis zip
|
||||
- name: publish
|
||||
env:
|
||||
REQUIRE_WINDOWS: "1"
|
||||
GITEA_RESOLVE: git.akarpov.ru:443:79.142.92.250
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) sh tools/release/release.sh "$GITHUB_REF_NAME" ;;
|
||||
refs/heads/master) sh tools/release/release.sh nightly ;;
|
||||
esac
|
||||
|
||||
@@ -17,4 +17,13 @@ tools/genicons/genicons
|
||||
|
||||
/tests/golden/testdata/actual-*
|
||||
tools/websmoke/node_modules/
|
||||
/tools/websmoke/__pycache__/
|
||||
tools/websmoke/golden/actual-*.png
|
||||
|
||||
# build output belongs in .cache/bin; these catch stray builds in the root
|
||||
/aego-*
|
||||
/i18n
|
||||
*.prof
|
||||
*.pprof
|
||||
/data*.txt
|
||||
*.key
|
||||
|
||||
+386
-34
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
# Aego — A 2D game engine built in Go.
|
||||
|
||||
Build games visually, write gameplay logic in Aego Script, and extend the engine with native Go.
|
||||
|
||||
A complete development environment with a visual editor, hot reload, deterministic simulation, frame-by-frame rewind, and cross-platform export. From your first scene to a playable game on Windows, Linux, macOS, and the web.
|
||||
|
||||
- Documentation and the playground: <https://aego.akarpov.ru>
|
||||
- Installing and building from source: [engine/res/docs/install.md](engine/res/docs/install.md)
|
||||
- Contributing, check levels and rules for engine code: [docs/contributing.md](docs/contributing.md)
|
||||
- Architecture and decisions: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Animation
|
||||
|
||||
`anim` provides the shared animation kernel for clips, keyframe tracks, events, and controller graphs. It samples tracks, evaluates state transitions and blending, and encodes clips and controllers in text and binary forms for use by the engine and editor.
|
||||
+238
File diff suppressed because it is too large
Load Diff
+234
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+109
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func clip(loop LoopMode, ts ...float32) *Clip {
|
||||
c := &Clip{Length: 1, Loop: loop}
|
||||
for _, t := range ts {
|
||||
c.Events = append(c.Events, Event{T: t, Name: "e"})
|
||||
}
|
||||
c.Fix()
|
||||
return c
|
||||
}
|
||||
|
||||
func evs(cs []Crossing) []int32 {
|
||||
out := make([]int32, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = c.Event
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cycles(cs []Crossing) []int64 {
|
||||
out := make([]int64, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = c.Cycle
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expect[T comparable](t *testing.T, name string, got, want []T) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("%s: got %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnce(t *testing.T) {
|
||||
c := clip(LoopOnce, 0, 0.5, 1)
|
||||
expect(t, "start", evs(Crossings(c, LoopOnce, 0, 0.6, true, nil)), []int32{0, 1})
|
||||
expect(t, "end", evs(Crossings(c, LoopOnce, 0.6, 1.5, false, nil)), []int32{2})
|
||||
expect(t, "after", evs(Crossings(c, LoopOnce, 1, 2, false, nil)), []int32{})
|
||||
}
|
||||
|
||||
func TestZeroKeyEveryCycle(t *testing.T) {
|
||||
c := clip(LoopRepeat, 0)
|
||||
expect(t, "start", cycles(Crossings(c, LoopRepeat, 0, 0.5, true, nil)), []int64{0})
|
||||
expect(t, "next", cycles(Crossings(c, LoopRepeat, 0.5, 1.5, false, nil)), []int64{1})
|
||||
expect(t, "two", cycles(Crossings(c, LoopRepeat, 1.5, 3.2, false, nil)), []int64{2, 3})
|
||||
}
|
||||
|
||||
func TestLengthKeyOncePerCycle(t *testing.T) {
|
||||
c := clip(LoopRepeat, 1)
|
||||
expect(t, "start", evs(Crossings(c, LoopRepeat, 0, 0.5, true, nil)), []int32{})
|
||||
expect(t, "first", cycles(Crossings(c, LoopRepeat, 0.5, 1, false, nil)), []int64{0})
|
||||
expect(t, "second", cycles(Crossings(c, LoopRepeat, 1, 2, false, nil)), []int64{1})
|
||||
}
|
||||
|
||||
func TestSameMoment(t *testing.T) {
|
||||
c := clip(LoopOnce, 0.5, 0.5)
|
||||
expect(t, "forward", evs(Crossings(c, LoopOnce, 0, 1, false, nil)), []int32{0, 1})
|
||||
expect(t, "backward", evs(Crossings(c, LoopOnce, 1, 0, false, nil)), []int32{0, 1})
|
||||
}
|
||||
|
||||
func TestStillAndBackward(t *testing.T) {
|
||||
c := clip(LoopRepeat, 0.2, 0.8)
|
||||
expect(t, "speed 0", evs(Crossings(c, LoopRepeat, 0.3, 0.3, false, nil)), []int32{})
|
||||
got := Crossings(c, LoopRepeat, 1.5, 0.1, false, nil)
|
||||
expect(t, "backward events", evs(got), []int32{0, 1, 0})
|
||||
expect(t, "backward cycles", cycles(got), []int64{1, 0, 0})
|
||||
}
|
||||
|
||||
func TestManyCycles(t *testing.T) {
|
||||
c := clip(LoopRepeat, 0.5)
|
||||
expect(t, "three", cycles(Crossings(c, LoopRepeat, 0, 3, true, nil)), []int64{0, 1, 2})
|
||||
}
|
||||
|
||||
func TestPingPongTurns(t *testing.T) {
|
||||
c := clip(LoopPingPong, 0, 1)
|
||||
got := Crossings(c, LoopPingPong, 0, 4, true, nil)
|
||||
expect(t, "events", evs(got), []int32{0, 1, 0, 1, 0})
|
||||
expect(t, "cycles", cycles(got), []int64{0, 0, 2, 2, 4})
|
||||
mid := clip(LoopPingPong, 0.25)
|
||||
got = Crossings(mid, LoopPingPong, 0, 2, true, nil)
|
||||
if len(got) != 2 || got[0].T != 0.25 || got[1].T != 1.75 {
|
||||
t.Fatalf("mid %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossingsNoAllocs(t *testing.T) {
|
||||
c := clip(LoopRepeat, 0, 0.25, 0.5, 0.75)
|
||||
buf := make([]Crossing, 0, 64)
|
||||
if n := testing.AllocsPerRun(100, func() { buf = Crossings(c, LoopRepeat, 0.1, 3.9, false, buf) }); n != 0 {
|
||||
t.Fatalf("Crossings allocates %v", n)
|
||||
}
|
||||
}
|
||||
+332
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"aego/format/aetext"
|
||||
)
|
||||
|
||||
const layoutSrc = `controller version=1 {
|
||||
entry="Idle"
|
||||
param "Speed" kind=float {}
|
||||
state "Idle" {}
|
||||
state "Run" {}
|
||||
state "Jump" pos=[500, -40] {}
|
||||
state "Lost" {}
|
||||
transition "Idle" to="Run" duration=0.1 {
|
||||
cond "Speed" op=gt value=0.1 {}
|
||||
}
|
||||
transition "Run" to="Idle" duration=0.1 {
|
||||
cond "Speed" op=le value=0.1 {}
|
||||
}
|
||||
transition "any" to="Jump" {
|
||||
cond "Speed" op=lt value=0 {}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func layoutGraph(t *testing.T) *Graph {
|
||||
t.Helper()
|
||||
doc, err := aetext.Parse([]byte(layoutSrc), "l.aectrl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, probs, err := DecodeGraph(doc)
|
||||
if err != nil || len(probs) > 0 {
|
||||
t.Fatalf("%v %+v", err, probs)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestLayoutPlacesEveryStateApart(t *testing.T) {
|
||||
g := layoutGraph(t)
|
||||
g.Layout()
|
||||
seen := map[[2]float32]string{}
|
||||
for _, s := range g.States {
|
||||
if !s.HasPos {
|
||||
t.Fatalf("%s has no position", s.Name)
|
||||
}
|
||||
if o, dup := seen[s.Pos]; dup {
|
||||
t.Fatalf("%s and %s share %v", s.Name, o, s.Pos)
|
||||
}
|
||||
seen[s.Pos] = s.Name
|
||||
}
|
||||
idle, run, jump := g.States[0].Pos, g.States[1].Pos, g.States[2].Pos
|
||||
if idle[0] != 0 || run[0] != LayoutColumn {
|
||||
t.Fatalf("entry %v, next %v: the entry is the first column", idle, run)
|
||||
}
|
||||
if jump != [2]float32{500, -40} {
|
||||
t.Fatalf("a written position moved to %v", jump)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPosRoundTrips(t *testing.T) {
|
||||
g := layoutGraph(t)
|
||||
g.Layout()
|
||||
text := aetext.Write(EncodeGraph(g))
|
||||
doc, err := aetext.Parse(text, "l.aectrl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, _, err := DecodeGraph(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin, err := UnmarshalGraph(MarshalGraph(back))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range g.States {
|
||||
if back.States[i].Pos != g.States[i].Pos || bin.States[i].Pos != g.States[i].Pos {
|
||||
t.Fatalf("%s: text %v binary %v, want %v", g.States[i].Name, back.States[i].Pos, bin.States[i].Pos, g.States[i].Pos)
|
||||
}
|
||||
}
|
||||
if _, err := UnmarshalGraph(MarshalGraph(back)[:len(MarshalGraph(back))-4]); err == nil {
|
||||
t.Fatal("a truncated state was accepted")
|
||||
}
|
||||
}
|
||||
+185
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
package anim
|
||||
|
||||
// RootDelta is the change of a root track between two clip times: a looping clip adds its end minus its start for every cycle crossed, a ping-pong clip only moves back and forth.
|
||||
func RootDelta(c *Clip, s *Sampler, track int, loop LoopMode, t0, t1 float64) [2]float32 {
|
||||
if !(c.Length > 0) || t0 == t1 || track < 0 || track >= len(c.Tracks) {
|
||||
return [2]float32{}
|
||||
}
|
||||
u0, k0 := c.Local(loop, t0)
|
||||
u1, k1 := c.Local(loop, t1)
|
||||
a := s.Track(c, track, u0).L
|
||||
b := s.Track(c, track, u1).L
|
||||
d := [2]float32{b[0] - a[0], b[1] - a[1]}
|
||||
if loop == LoopRepeat && k1 != k0 {
|
||||
start := s.Track(c, track, 0).L
|
||||
end := s.Track(c, track, c.Length).L
|
||||
n := float32(k1 - k0)
|
||||
d[0] += n * (end[0] - start[0])
|
||||
d[1] += n * (end[1] - start[1])
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"aego/core/curve"
|
||||
"aego/schema"
|
||||
)
|
||||
|
||||
func walkClip(loop LoopMode) *Clip {
|
||||
c := &Clip{Length: 1, Loop: loop, Tracks: []Track{{
|
||||
Property: RootProp, Kind: schema.KindVec2, Root: true,
|
||||
Keys: []Key{{T: 0, V: Lanes([4]float32{0, 0}), Interp: curve.InterpLinear}, {T: 1, V: Lanes([4]float32{1, 0.5})}},
|
||||
}}}
|
||||
c.Fix()
|
||||
return c
|
||||
}
|
||||
|
||||
func TestRootDelta(t *testing.T) {
|
||||
var s Sampler
|
||||
loop := walkClip(LoopRepeat)
|
||||
for _, tc := range []struct {
|
||||
t0, t1 float64
|
||||
want [2]float32
|
||||
}{
|
||||
{0.25, 0.75, [2]float32{0.5, 0.25}},
|
||||
{0.5, 1.5, [2]float32{1, 0.5}},
|
||||
{0.25, 2.25, [2]float32{2, 1}},
|
||||
{1.25, 0.25, [2]float32{-1, -0.5}},
|
||||
{0.75, 1, [2]float32{0.25, 0.125}},
|
||||
} {
|
||||
d := RootDelta(loop, &s, 0, LoopRepeat, tc.t0, tc.t1)
|
||||
if !near(d[0], tc.want[0]) || !near(d[1], tc.want[1]) {
|
||||
t.Fatalf("loop %v..%v: %v, want %v", tc.t0, tc.t1, d, tc.want)
|
||||
}
|
||||
}
|
||||
ping := walkClip(LoopPingPong)
|
||||
if d := RootDelta(ping, &s, 0, LoopPingPong, 0.5, 1.5); !near(d[0], 0) || !near(d[1], 0) {
|
||||
t.Fatalf("ping-pong turns back: %v", d)
|
||||
}
|
||||
once := walkClip(LoopOnce)
|
||||
if d := RootDelta(once, &s, 0, LoopOnce, 0.75, 3); !near(d[0], 0.25) {
|
||||
t.Fatalf("once stops at the end: %v", d)
|
||||
}
|
||||
if d := RootDelta(loop, &s, 0, LoopRepeat, 0.5, 0.5); d != ([2]float32{}) {
|
||||
t.Fatalf("no time, no motion: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCheck(t *testing.T) {
|
||||
c := walkClip(LoopRepeat)
|
||||
if ps := c.Check(nil); len(ps) != 0 {
|
||||
t.Fatalf("a good root track reports %+v", ps)
|
||||
}
|
||||
c.Tracks[0].Target = "Body"
|
||||
if ps := c.Check(nil); len(ps) != 1 || ps[0].Kind != ProblemRoot {
|
||||
t.Fatalf("a root track on a child: %+v", ps)
|
||||
}
|
||||
}
|
||||
+146
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aego/format/aetext"
|
||||
)
|
||||
|
||||
// shape lists the attributes, fields and child blocks a block of a clip or controller may hold.
|
||||
type shape struct {
|
||||
attrs []string
|
||||
fields []string
|
||||
blocks []string
|
||||
}
|
||||
|
||||
var (
|
||||
clipShape = shape{attrs: []string{"version", "length", "fps", "loop"}, blocks: []string{"track", "frames", "event"}}
|
||||
trackShape = shape{attrs: []string{"prop", "kind", "numeric", "root", "authority", "onEnd"}, blocks: []string{"key"}}
|
||||
keyShape = shape{attrs: []string{"t", "v", "interp", "tangent", "in", "out"}}
|
||||
framesShape = shape{attrs: []string{"start", "fps", "prop", "flipProp", "authority"}, fields: []string{"sprites", "durations", "flipX"}}
|
||||
eventShape = shape{attrs: []string{"t", "name", "payload", "mode"}}
|
||||
ctrlShape = shape{attrs: []string{"version", "ease", "entryPos", "anyPos"}, fields: []string{"entry"}, blocks: []string{"param", "state", "transition"}}
|
||||
paramShape = shape{attrs: []string{"kind", "default"}}
|
||||
stateShape = shape{attrs: []string{"clip", "speed", "loop", "pos", "events"}}
|
||||
transShape = shape{attrs: []string{"to", "duration", "exitTime", "interrupt"}, blocks: []string{"cond"}}
|
||||
condShape = shape{attrs: []string{"op", "value"}}
|
||||
)
|
||||
|
||||
// opening is how the block's first line reads with the attribute moved into it.
|
||||
func opening(n *aetext.Node, f aetext.Field) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Head)
|
||||
if n.Label != "" || n.Head == "frames" || n.Head == "track" {
|
||||
b.WriteString(" " + strconv.Quote(n.Label))
|
||||
}
|
||||
b.WriteString(" " + f.Name + "=" + aetext.FormatValue(f.Value) + " {")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// strict refuses names a block does not know, so a misspelt or misplaced setting is never dropped silently.
|
||||
func strict(n *aetext.Node, s shape) error {
|
||||
for _, f := range n.Attrs {
|
||||
switch {
|
||||
case slices.Contains(s.attrs, f.Name):
|
||||
case slices.Contains(s.fields, f.Name):
|
||||
return textErr(n, f.Name+" is a field of "+n.Head+": write "+f.Name+"="+aetext.FormatValue(f.Value)+" inside the block")
|
||||
default:
|
||||
return textErr(n, "unknown attribute "+f.Name+" of "+n.Head+"; it takes "+strings.Join(s.attrs, ", "))
|
||||
}
|
||||
}
|
||||
for _, f := range n.Fields {
|
||||
switch {
|
||||
case slices.Contains(s.fields, f.Name):
|
||||
case slices.Contains(s.attrs, f.Name):
|
||||
return textErr(n, f.Name+" is an attribute of "+n.Head+": write `"+opening(n, f)+"`")
|
||||
case len(s.fields) == 0:
|
||||
return textErr(n, "unknown field "+f.Name+": "+n.Head+" takes no fields")
|
||||
default:
|
||||
return textErr(n, "unknown field "+f.Name+" of "+n.Head+"; it takes "+strings.Join(s.fields, ", "))
|
||||
}
|
||||
}
|
||||
for _, b := range n.Blocks {
|
||||
if !slices.Contains(s.blocks, b.Head) {
|
||||
if len(s.blocks) == 0 {
|
||||
return textErr(b, n.Head+" holds no blocks, found "+b.Head)
|
||||
}
|
||||
return textErr(b, "unknown block "+b.Head+" in "+n.Head+"; it holds "+strings.Join(s.blocks, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aego/format/aetext"
|
||||
)
|
||||
|
||||
func decodeText(t *testing.T, src string, ctrl bool) error {
|
||||
t.Helper()
|
||||
n, err := aetext.Parse([]byte(src), "t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctrl {
|
||||
_, _, err = DecodeGraph(n)
|
||||
} else {
|
||||
_, err = Decode(n)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Every block of a clip refuses names it does not know instead of dropping them.
|
||||
func TestClipRefusesUnknownNames(t *testing.T) {
|
||||
cases := []struct{ name, src, want string }{
|
||||
{"root", `anim version=1 length=1 speed=2 {}`, "unknown attribute speed of anim"},
|
||||
{"root block", "anim version=1 length=1 {\n curve \"\" {}\n}\n", "unknown block curve"},
|
||||
{"track", "anim version=1 length=1 {\n track \"\" prop=\"SpriteRenderer.size\" kind=vec2 loop=true {}\n}\n", "unknown attribute loop of track"},
|
||||
{"key", "anim version=1 length=1 {\n track \"\" prop=\"SpriteRenderer.size\" kind=vec2 {\n key t=0 v=[1, 1] ease=linear {}\n }\n}\n", "unknown attribute ease of key"},
|
||||
{"frames", "anim version=1 length=1 {\n frames \"Slash\" {\n sprites=[asset(\"ae900000000000000000000000000b01\")]\n start=0.08\n }\n}\n", "start is an attribute of frames: write `frames \"Slash\" start=0.08 {`"},
|
||||
{"frames field", "anim version=1 length=1 {\n frames \"\" {\n sprites=[asset(\"ae900000000000000000000000000b01\")]\n duration=[0.1]\n }\n}\n", "unknown field duration of frames"},
|
||||
{"event", "anim version=1 length=1 {\n event t=0.1 name=\"Hit\" when=1 {}\n}\n", "unknown attribute when of event"},
|
||||
{"event field", "anim version=1 length=1 {\n event t=0.1 name=\"Hit\" {\n payload=1\n }\n}\n", "payload is an attribute of event"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := decodeText(t, c.src, false)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: got %v, want %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every block of a controller refuses names it does not know instead of dropping them.
|
||||
func TestControllerRefusesUnknownNames(t *testing.T) {
|
||||
cases := []struct{ name, src, want string }{
|
||||
{"root", "controller version=1 start=\"Idle\" {\n state \"Idle\" {}\n}\n", "unknown attribute start of controller"},
|
||||
{"root field", "controller version=1 {\n ease=linear\n state \"Idle\" {}\n}\n", "ease is an attribute of controller"},
|
||||
{"param", "controller version=1 {\n param \"Speed\" kind=float min=0 {}\n}\n", "unknown attribute min of param"},
|
||||
{"state", "controller version=1 {\n state \"Idle\" clipp=asset(\"a0a10000000000000000000000000005\") {}\n}\n", "unknown attribute clipp of state"},
|
||||
{"transition", "controller version=1 {\n state \"A\" {}\n state \"B\" {}\n transition \"A\" to=\"B\" exit=1 {}\n}\n", "unknown attribute exit of transition"},
|
||||
{"cond", "controller version=1 {\n param \"Speed\" kind=float {}\n state \"A\" {}\n state \"B\" {}\n transition \"A\" to=\"B\" {\n cond \"Speed\" op=gt val=1 {}\n }\n}\n", "unknown attribute val of cond"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := decodeText(t, c.src, true)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: got %v, want %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+521
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"aego/core/ids"
|
||||
"aego/format/aetext"
|
||||
"aego/schema"
|
||||
)
|
||||
|
||||
type Value struct {
|
||||
L [4]float32
|
||||
I int64
|
||||
A ids.AssetID
|
||||
}
|
||||
|
||||
func Float(f float32) Value { return Value{L: [4]float32{f}} }
|
||||
func Lanes(l [4]float32) Value { return Value{L: l} }
|
||||
func Int(i int64) Value { return Value{I: i} }
|
||||
func Asset(id ids.AssetID) Value { return Value{A: id} }
|
||||
func (v Value) Truth() bool { return v.I != 0 }
|
||||
|
||||
func Bool(b bool) Value {
|
||||
if b {
|
||||
return Value{I: 1}
|
||||
}
|
||||
return Value{}
|
||||
}
|
||||
|
||||
type class uint8
|
||||
|
||||
const (
|
||||
classNone class = iota
|
||||
classLanes
|
||||
classInt
|
||||
classDiscrete
|
||||
classQuat
|
||||
classAsset
|
||||
)
|
||||
|
||||
func classOf(k schema.Kind) class {
|
||||
switch k {
|
||||
case schema.KindFloat32, schema.KindFloat64, schema.KindVec2, schema.KindVec3, schema.KindVec4, schema.KindColor, schema.KindRect:
|
||||
return classLanes
|
||||
case schema.KindInt32, schema.KindInt64:
|
||||
return classInt
|
||||
case schema.KindBool, schema.KindEnum:
|
||||
return classDiscrete
|
||||
case schema.KindQuat:
|
||||
return classQuat
|
||||
case schema.KindAssetRef:
|
||||
return classAsset
|
||||
}
|
||||
return classNone
|
||||
}
|
||||
|
||||
func lanesOf(k schema.Kind) int {
|
||||
switch k {
|
||||
case schema.KindFloat32, schema.KindFloat64:
|
||||
return 1
|
||||
case schema.KindVec2:
|
||||
return 2
|
||||
case schema.KindVec3:
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
func Supported(k schema.Kind) bool {
|
||||
return classOf(k) != classNone
|
||||
}
|
||||
|
||||
func Interpolates(k schema.Kind) bool {
|
||||
c := classOf(k)
|
||||
return c == classLanes || c == classQuat
|
||||
}
|
||||
|
||||
// LaneCount is the number of float lanes of a lane kind, 0 for discrete kinds.
|
||||
func LaneCount(k schema.Kind) int {
|
||||
if classOf(k) != classLanes && classOf(k) != classQuat {
|
||||
return 0
|
||||
}
|
||||
return lanesOf(k)
|
||||
}
|
||||
|
||||
// ValueText writes v the way a key stores it in .aeanim.
|
||||
func ValueText(kind schema.Kind, v Value) aetext.Value {
|
||||
return writeValue(kind, v)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# app
|
||||
|
||||
The `app` packages assemble the engine, modules, asset pipeline, and scripting workspace into development hosts. They coordinate workflows such as project creation, editing, running, and exporting outside the engine core.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tree(depth, fan int) NodeView {
|
||||
v := NodeView{Name: "n"}
|
||||
if depth > 0 {
|
||||
for i := 0; i < fan; i++ {
|
||||
v.Children = append(v.Children, tree(depth-1, fan))
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestCapTreeKeepsTheFirstNodes(t *testing.T) {
|
||||
v := tree(4, 4)
|
||||
all := nodeCount(&v)
|
||||
left := capTree(&v, 50, 0)
|
||||
if kept := nodeCount(&v); kept != 50 || kept+left != all {
|
||||
t.Fatalf("kept %d and left %d of %d", kept, left, all)
|
||||
}
|
||||
more := 0
|
||||
var walk func(n *NodeView)
|
||||
walk = func(n *NodeView) {
|
||||
more += n.More
|
||||
for i := range n.Children {
|
||||
walk(&n.Children[i])
|
||||
}
|
||||
}
|
||||
walk(&v)
|
||||
if more != left {
|
||||
t.Fatalf("the more counts add up to %d, %d were left out", more, left)
|
||||
}
|
||||
v = tree(4, 2)
|
||||
if left := capTree(&v, 0, 2); nodeCount(&v) != 7 || left != 31-7 {
|
||||
t.Fatalf("depth 2 kept %d nodes", nodeCount(&v))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptWindow(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 1000; i++ {
|
||||
b.WriteString("line " + strconv.Itoa(i) + "\n")
|
||||
}
|
||||
text := b.String()
|
||||
if got, note := window(text, 0, 0, 2000); got != text || note != "" {
|
||||
t.Fatal("a short script is not read whole")
|
||||
}
|
||||
got, note := window(text, 0, 0, 400)
|
||||
if !strings.HasPrefix(got, "line 1\n") || !strings.HasSuffix(got, "line 400\n") || !strings.Contains(note, "1-400 of 1001") {
|
||||
t.Fatalf("the first window is wrong: %q", note)
|
||||
}
|
||||
got, _ = window(text, 600, 620, 400)
|
||||
if !strings.HasPrefix(got, "line 600\n") || !strings.HasSuffix(got, "line 620\n") {
|
||||
t.Fatalf("the asked range is wrong: %q", got[:20])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustFind(t *testing.T, name string) *Command {
|
||||
t.Helper()
|
||||
c, ok := Find(name)
|
||||
if !ok {
|
||||
t.Fatalf("no command %s", name)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestParseArgs(t *testing.T) {
|
||||
set := mustFind(t, "scene.set")
|
||||
a, f, err := parseArgs(set, []string{"s.aescene", "--json", "Player", "--dry-run", "Body2D.mass", "-3"})
|
||||
if err != nil || !f.json || a.Str("value") != "-3" || a.Str("node") != "Player" || !a.Bool("dry-run") {
|
||||
t.Fatalf("flags after positionals: %v %+v %v", a, f, err)
|
||||
}
|
||||
add := mustFind(t, "scene.add-node")
|
||||
for _, toks := range [][]string{{"s", "N", "--index=1"}, {"s", "N", "--index", "1"}} {
|
||||
a, _, err := parseArgs(add, toks)
|
||||
if n, _ := a.Int("index", -1); err != nil || n != 1 {
|
||||
t.Fatalf("%v: %v %v", toks, a, err)
|
||||
}
|
||||
}
|
||||
a, _, err = parseArgs(set, []string{"s", "--", "Player", "Body2D.mass", "--odd"})
|
||||
if err != nil || a.Str("value") != "--odd" {
|
||||
t.Fatalf("after --: %v %v", a, err)
|
||||
}
|
||||
if _, _, err := parseArgs(set, []string{"s", "--nope"}); err == nil {
|
||||
t.Fatal("an unknown flag was accepted")
|
||||
}
|
||||
if _, _, err := parseArgs(set, []string{"s", "a", "b", "c", "d"}); err == nil {
|
||||
t.Fatal("an extra positional was accepted")
|
||||
}
|
||||
var out, errw bytes.Buffer
|
||||
if code := Main(nil, []string{"scene", "set", "s", "--nope"}, &out, &errw, Options{}); code != 2 {
|
||||
t.Fatalf("unknown flag exit %d", code)
|
||||
}
|
||||
if code := Main(nil, []string{"scene", "set", "a", "b", "c", "d", "e"}, &out, &errw, Options{}); code != 2 {
|
||||
t.Fatalf("extra positional exit %d", code)
|
||||
}
|
||||
if code := Main(nil, []string{"scene", "help"}, &out, &errw, Options{}); code != 0 {
|
||||
t.Fatalf("help exit %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemasAndToolNames(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, c := range Commands() {
|
||||
if seen[c.Tool()] {
|
||||
t.Fatalf("tool name %s is used twice", c.Tool())
|
||||
}
|
||||
seen[c.Tool()] = true
|
||||
b, err := json.Marshal(c.Schema())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sch struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]map[string]any `json:"properties"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &sch); err != nil || sch.Type != "object" {
|
||||
t.Fatalf("%s: %s", c.Name, b)
|
||||
}
|
||||
var want []string
|
||||
for _, x := range c.AllArgs() {
|
||||
if x.Required {
|
||||
want = append(want, x.Name)
|
||||
}
|
||||
if _, ok := sch.Properties[x.Name]; !ok {
|
||||
t.Fatalf("%s: no property %s", c.Name, x.Name)
|
||||
}
|
||||
}
|
||||
if len(want) != len(sch.Required) {
|
||||
t.Fatalf("%s: required %v, want %v", c.Name, sch.Required, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"aego/app/agent"
|
||||
)
|
||||
|
||||
// Relay passes an outside agent's MCP session to the open editor, so its changes become the editor's Undo steps, named after the client.
|
||||
func Relay(in io.Reader, out io.Writer, addr, token string) error {
|
||||
client := "agent"
|
||||
sc := bufio.NewScanner(in)
|
||||
sc.Buffer(make([]byte, 0, 1<<20), maxBody)
|
||||
for sc.Scan() {
|
||||
line := sc.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
params, _ := msg["params"].(map[string]any)
|
||||
switch msg["method"] {
|
||||
case "initialize":
|
||||
if info, ok := params["clientInfo"].(map[string]any); ok {
|
||||
if name, ok := info["name"].(string); ok && name != "" {
|
||||
client = name
|
||||
}
|
||||
}
|
||||
case "tools/call":
|
||||
if params != nil {
|
||||
args, _ := params["arguments"].(map[string]any)
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
args[agent.ViaArg] = "via agent: " + client
|
||||
params["arguments"] = args
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(msg)
|
||||
rep, err := agent.ProxyPost(addr, token, body)
|
||||
if err != nil {
|
||||
id, ok := msg["id"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rep, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32000, "message": "the editor closed; start the MCP server again: " + err.Error()}})
|
||||
}
|
||||
if len(rep) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := out.Write(append(rep, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"aego/app/agent"
|
||||
"aego/project"
|
||||
)
|
||||
|
||||
// Source runs the tool catalog: the CLI opens a session per call, the editor runs on its own documents (D341).
|
||||
type Source interface {
|
||||
Run(name string, a agent.Args) (*agent.Output, error)
|
||||
Paths() ([]string, error)
|
||||
Project() *project.Project
|
||||
Rel(p string) (string, error)
|
||||
}
|
||||
|
||||
// Extra is a resource a Source adds to the list, such as the editor's selection.
|
||||
type Extra struct {
|
||||
URI string
|
||||
Name string
|
||||
Description string
|
||||
MimeType string
|
||||
Read func() (string, error)
|
||||
}
|
||||
|
||||
// Tool is a tool the host adds next to the agent commands, such as the editor's ask_user.
|
||||
type Tool struct {
|
||||
Name string
|
||||
Description string
|
||||
Schema map[string]any
|
||||
ReadOnly bool
|
||||
// Run answers the call; it may block while the user decides.
|
||||
Run func(args map[string]any) (text string, failed bool)
|
||||
Output func(map[string]any) (*agent.Output, error)
|
||||
}
|
||||
|
||||
type kitSource struct {
|
||||
kit *agent.Kit
|
||||
}
|
||||
|
||||
func (k kitSource) Run(name string, a agent.Args) (*agent.Output, error) {
|
||||
sess, err := k.kit.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sess.Close()
|
||||
return agent.Run(sess, name, a)
|
||||
}
|
||||
|
||||
func (k kitSource) Paths() ([]string, error) {
|
||||
sess, err := k.kit.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sess.Close()
|
||||
var out []string
|
||||
for _, a := range sess.Pipeline.Assets() {
|
||||
out = append(out, a.Path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k kitSource) Project() *project.Project {
|
||||
return k.kit.Project
|
||||
}
|
||||
|
||||
func (k kitSource) Rel(p string) (string, error) {
|
||||
return k.kit.Rel(p)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"aego/modules/render2d"
|
||||
"aego/scene/mutate"
|
||||
)
|
||||
|
||||
type postCameraHost struct {
|
||||
Host
|
||||
doc *mutate.Document
|
||||
edits int
|
||||
}
|
||||
|
||||
func (h *postCameraHost) OpenScene(string) (*mutate.Document, bool) { return h.doc, true }
|
||||
func (h *postCameraHost) ApplyScene(_ string, _ string, tx mutate.Tx) (*mutate.Result, error) {
|
||||
r, err := h.doc.Apply(tx)
|
||||
if err == nil {
|
||||
h.edits++
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func TestPostEffectCommandTargetsDirtyCameraByStableID(t *testing.T) {
|
||||
s := materialSession(t)
|
||||
const file = "scenes/test.aescene"
|
||||
disk := []byte(`scene version=1 {
|
||||
node "43980000000000000000000000000001" name="Camera" {
|
||||
component "engine.Camera2D" version=1 {}
|
||||
component "render2d.PostProcess" version=1 { enabled=true effects=[{typeID="postfx.bloom" instanceID="first" enabled=true version=1 parameters=[]}] }
|
||||
}
|
||||
}`)
|
||||
full := filepath.Join(s.Project.Root, file)
|
||||
if err := os.WriteFile(full, disk, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, doc, err := s.sceneDisk(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = doc.Apply(mutate.Tx{Label: "Unsaved rename", Ops: []mutate.Op{mutate.Rename("Camera", "UnsavedCamera")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &postCameraHost{doc: doc}
|
||||
s.host = h
|
||||
a := Args{"file": file, "node": "UnsavedCamera", "instance": "first", "field": "Intensity", "value": "0.37", "dry-run": true}
|
||||
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 0 {
|
||||
t.Fatalf("dry run: %v / %d", err, h.edits)
|
||||
}
|
||||
delete(a, "dry-run")
|
||||
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 1 {
|
||||
t.Fatalf("apply: %v / %d", err, h.edits)
|
||||
}
|
||||
o, err := Run(s, "postfx.get", Args{"file": file, "node": "UnsavedCamera"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stack := o.Value.(render2d.PostProcess)
|
||||
e, ok := stack.Effect("first")
|
||||
if !ok || len(e.Parameters) != 1 || e.Parameters[0].Value.X != 0.37 {
|
||||
t.Fatal(stack)
|
||||
}
|
||||
for _, bad := range []string{"-1", "true", "[1,2]"} {
|
||||
a["value"] = bad
|
||||
if _, err := Run(s, "postfx.set", a); err == nil || h.edits != 1 {
|
||||
t.Fatalf("invalid %q wrote scene", bad)
|
||||
}
|
||||
}
|
||||
a["instance"] = "deleted"
|
||||
if _, err := Run(s, "postfx.set", a); err == nil {
|
||||
t.Fatal("missing instance accepted")
|
||||
}
|
||||
if b, err := os.ReadFile(full); err != nil || string(b) != string(disk) {
|
||||
t.Fatal("saved document changed")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user