Files
aego/app/edit/platformer_assets_test.go
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

329 lines
10 KiB
Go

package edit
import (
"bytes"
"io/fs"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"aego/asset"
"aego/core/ids"
"aego/core/mathx"
"aego/editor/core/command"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/engine"
"aego/format/aetext"
inputmodule "aego/modules/input"
"aego/modules/render2d"
"aego/project"
"aego/render/texture"
"aego/scene"
"aego/scene/frame"
"aego/scene/mutate"
"aego/scene/sceneio"
"aego/sdk"
)
// This in-process registration is the same data contract as the canonical
// example's native/module.go; no second editor executable is needed by CI.
type platformerEnemyStats struct {
Speed float32 `aego:"speed,min=0,max=10"`
Cooldown float32 `aego:"cooldown,min=0,max=5"`
Damage int64 `aego:"damage,min=0"`
Tint mathx.Color `aego:"tint"`
}
type platformerAuthoringModule struct{}
func (platformerAuthoringModule) Info() sdk.ModuleInfo {
return sdk.ModuleInfo{ID: "mygame", Version: "0.1.0"}
}
func (platformerAuthoringModule) Register(r *sdk.Registry) error {
if err := sdk.DataType[platformerEnemyStats](r, sdk.Name("mygame.EnemyStats")).Err(); err != nil {
return err
}
sdk.Live[platformerEnemyStats](r, "speed")
return nil
}
func platformerAuthoringProject(t *testing.T) *project.Project {
t.Helper()
source := filepath.Join("..", "..", "examples", "platformer")
root := t.TempDir()
if err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
if entry.IsDir() {
switch rel {
case ".aego", "native", "exports":
return filepath.SkipDir
}
return os.MkdirAll(filepath.Join(root, rel), 0755)
}
if rel == "go.mod" || rel == "go.sum" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, rel), data, 0644)
}); err != nil {
t.Fatal(err)
}
p, err := project.Load(root)
if err != nil {
t.Fatal(err)
}
p.Native = false // supplied by platformerAuthoringModule in this process
return p
}
// gpu/null proves the canonical authoring and live resource transaction. The
// actual displayed Lit pixels are covered separately by the GL/browser route.
func TestPlatformerAssetSaveAndUndoDuringPlay(t *testing.T) {
p := platformerAuthoringProject(t)
s, err := Open(p, headless(), platformerAuthoringModule{})
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.Step()
ed := s.App.Context()
doc, err := ed.Docs.Open("scenes/main.aescene")
if err != nil {
t.Fatal(err)
}
s.Step()
beforeScene := sceneio.Save(doc.Scene())
beforePrefab, err := os.ReadFile(filepath.Join(p.Root, "prefabs", "enemy.aeprefab"))
if err != nil {
t.Fatal(err)
}
const albedoPath = "assets/surfaces/platform.png"
albedoID, ok := ed.Pipeline.Lookup(albedoPath)
if !ok {
t.Fatal("canonical platform texture missing")
}
ed.Open(context.AssetDocumentAt(albedoPath))
albedoDoc := ed.Docs.AssetDocument(albedoID)
if albedoDoc == nil || albedoDoc.Metadata == nil || albedoDoc.Path != albedoPath {
t.Fatal("existing platform image did not open through the shared document route")
}
meta, err := asset.ParseSourceMeta(albedoDoc.Metadata.Code.Bytes(), albedoDoc.Metadata.Path)
if err != nil {
t.Fatal(err)
}
heightID, explicit, err := asset.TextureChannel(meta, albedoID, "height")
if err != nil || !explicit || heightID.IsZero() {
t.Fatal("canonical platform is not connected to an explicit Height channel", err)
}
d, err := ed.OpenAsset(heightID)
if err != nil || d == nil || d.Path != "assets/surfaces/platform_height.png" || d.Metadata == nil {
t.Fatal("linked height channel did not open its shared source document", err)
}
if shared, err := ed.DocumentForAsset(heightID); err != nil || shared != d || d.Source != nil {
t.Fatal("Inspector and linked image do not share one metadata journal", err)
}
beforeSource, err := os.ReadFile(ed.Docs.Abs(d.Path))
if err != nil {
t.Fatal(err)
}
readNormal := func() []byte {
t.Helper()
e, ok := ed.Loader.Index().Lookup(heightID)
if !ok {
t.Fatal("height runtime entry missing")
}
if a, region := e.Artifact(asset.ArtSpriteRegion); region {
raw, err := ed.Loader.Artifacts().ReadFile(a.Path)
if err != nil {
t.Fatal(err)
}
mapping, err := asset.DecodeRegion(raw)
if err != nil {
t.Fatal(err)
}
e, ok = ed.Loader.Index().Lookup(mapping.Page)
if !ok {
t.Fatal("generated normal atlas page missing")
}
}
a, ok := e.Artifact(asset.ArtTexture)
if !ok {
a, ok = e.Artifact(asset.ArtAtlasPage)
}
if !ok {
t.Fatal("generated normal texture artifact missing")
}
raw, err := ed.Loader.Artifacts().ReadFile(a.Path)
if err != nil {
t.Fatal(err)
}
normal, err := asset.DecodeTexture(raw)
if err != nil || normal.Semantic != asset.TextureNormal {
t.Fatal("height settings did not generate a linear normal artifact", err)
}
return bytes.Clone(normal.Pixels)
}
initial := readNormal()
_, initialRegion, ok := ed.Loader.NormalFor(albedoID)
if !ok {
t.Fatal("canonical platform has no effective generated normal")
}
pc := s.App.Play()
if err := pc.Start(doc); err != nil {
t.Fatal(err)
}
playWorld := pc.World()
pc.Tick(1.0 / 60)
assertConsumers := func(want texture.Region) {
t.Helper()
if pc.World() != playWorld {
t.Fatal("asset authoring stopped or replaced the Play world")
}
s.Engine.RunPhase(playWorld, engine.PhaseRenderPrepare, engine.ScopePlay, 0, 1)
s.Engine.RunPhase(playWorld, engine.PhaseRender, engine.ScopePlay, 0, 1)
r := frame.Of(&engine.Context{World: playWorld})
albedo, ok := ed.Loader.Texture(albedoID)
if !ok || r == nil {
t.Fatal("live Play extraction has no platform source")
}
consumers := 0
for i := range r.Views() {
for _, draw := range r.Bucket(i).Sprites {
if draw.Region != albedo {
continue
}
consumers++
if !draw.Lighting || draw.Normal != want || !draw.HasLinear {
t.Fatal("canonical Play consumer retained a stale normal or basis")
}
}
}
if consumers < 2 {
t.Fatalf("the actual level must share the platform texture between multiple receivers: got=%d views=%d stats=%+v albedo=%+v", consumers, len(r.Views()), r.Stats(), albedo)
}
}
assertConsumers(initialRegion)
env, err := ed.TextureAuthoringEnv(heightID)
if err != nil {
t.Fatal(err)
}
if _, err := document.ApplyTextureMutation(d.Metadata, env, mutate.Tx{Label: "Platform height strength", Ops: []mutate.Op{mutate.TextureSetting("height_strength", aetext.Float32(9))}}, false); err != nil {
t.Fatal(err)
}
panelID := "render2d.image/" + d.Asset.String()
run := func(id string) {
t.Helper()
s.App.Workspace().Focus(panelID)
if err := ed.Commands.Run(id); err != nil {
t.Fatal(id, err)
}
}
run(command.FileSave)
s.Step()
saved := readNormal()
_, savedRegion, resolved := ed.Loader.NormalFor(albedoID)
if bytes.Equal(initial, saved) || d.Dirty() || !resolved || savedRegion.Texture == initialRegion.Texture {
t.Fatal("Save during Play did not publish changed normal pixels and a new shared GPU resource")
}
assertConsumers(savedRegion)
diskSaved, err := os.ReadFile(ed.Docs.Abs(d.Metadata.Path))
if err != nil {
t.Fatal(err)
}
awaitHistory := func(id string, pixels []byte, dirty bool) {
t.Helper()
previous := d.WorkingPreview.Applied
run(id)
deadline := time.Now().Add(8 * time.Second)
for d.WorkingPreview.Applied == previous && d.WorkingPreview.Error == "" && time.Now().Before(deadline) {
s.Step()
runtime.Gosched()
time.Sleep(time.Millisecond)
}
if d.WorkingPreview.Applied == previous || d.WorkingPreview.Pending || d.WorkingPreview.Error != "" || d.Dirty() != dirty || !bytes.Equal(pixels, readNormal()) {
t.Fatalf("canonical live asset history failed: %s, %+v, dirty=%v", id, d.WorkingPreview, d.Dirty())
}
current, err := os.ReadFile(ed.Docs.Abs(d.Metadata.Path))
if err != nil || !bytes.Equal(current, diskSaved) {
t.Fatal("live Undo/Redo silently wrote the saved source metadata", err)
}
_, normal, ok := ed.Loader.NormalFor(albedoID)
if !ok {
t.Fatal("live Undo/Redo lost the saved source association")
}
assertConsumers(normal)
}
awaitHistory(command.EditUndo, initial, true)
awaitHistory(command.EditRedo, saved, false)
// Exercise the real character script and animation. Each emitted draw must
// use the current stable frame's own normal region, with FlipX carried once.
player := pc.Scene().Find(pc.Scene().Root(), "Player")
inputs := inputmodule.Of(playWorld)
if player.IsZero() || inputs == nil {
t.Fatal("canonical Player or Input Actions missing")
}
frames := map[ids.AssetID]bool{}
for _, move := range []float32{1, -1} {
if err := inputs.Inject(0, "Move", move); err != nil {
t.Fatal(err)
}
for range 30 {
pc.Tick(1.0 / 60)
s.Engine.RunPhase(playWorld, engine.PhaseRenderPrepare, engine.ScopePlay, 0, 1)
s.Engine.RunPhase(playWorld, engine.PhaseRender, engine.ScopePlay, 0, 1)
sr, ok := scene.Get[render2d.SpriteRenderer](pc.Scene(), player)
if !ok || sr.FlipX != (move < 0) {
t.Fatal("real player movement did not update FlipX")
}
frames[sr.Sprite.ID] = true
albedo, ok := sr.Sprite.Region(ed.Loader)
normalID, normal, resolved := ed.Loader.NormalFor(sr.Sprite.ID)
if !ok || !resolved || normalID.IsZero() || normal.Size() != albedo.Size() {
t.Fatal("animated runner frame lost its source-aligned normal region")
}
r := frame.Of(&engine.Context{World: playWorld})
found := false
for i := range r.Views() {
for _, draw := range r.Bucket(i).Sprites {
if draw.Region == albedo && draw.Order == int16(sr.Order) && draw.Tint == sr.Tint {
found = true
if draw.FlipX != sr.FlipX || draw.Normal != normal || !draw.Lighting || !draw.HasLinear {
t.Fatal("FlipX or the normal region changed between the real animation and extraction")
}
}
}
}
if !found {
t.Fatal("live player is absent from the actual Play draw stream")
}
}
}
if len(frames) < 2 {
t.Fatal("runner animation did not advance through distinct stable frame IDs")
}
pc.Stop()
if doc.Dirty() || !bytes.Equal(beforeScene, sceneio.Save(doc.Scene())) {
t.Fatal("asset Save/Undo or gameplay presentation modified EditScene")
}
if now, err := os.ReadFile(ed.Docs.Abs(d.Path)); err != nil || !bytes.Equal(now, beforeSource) {
t.Fatal("height metadata authoring edited the source image pixels", err)
}
if now, err := os.ReadFile(filepath.Join(p.Root, "prefabs", "enemy.aeprefab")); err != nil || !bytes.Equal(now, beforePrefab) {
t.Fatal("live asset authoring changed a gameplay prefab", err)
}
}