6 Commits
Author SHA1 Message Date
sanspie 1d7cee7cc7 test: update light scene preview goldens
github-native / darwin amd64 native (push) Waiting to run
github-native / windows amd64 native (push) Waiting to run
github-native / darwin arm64 native (push) Waiting to run
ci-platforms / windows amd64 smoke (push) Skipped
github-native / linux amd64 native (push) Skipped
ci / checks (push) Successful in 6m34s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m53s
ci / release (push) Skipped
2026-11-06 17:11:45 +03:00
sanspie 85d9efc67b ci: make repository paths portable to Windows 2026-11-06 17:11:18 +03:00
sanspie ab6ea7ef58 editor: improve scene tools and prepare native CI
github-native / darwin amd64 native (push) Waiting to run
github-native / windows amd64 native (push) Waiting to run
github-native / darwin arm64 native (push) Waiting to run
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / linux amd64 native (push) Skipped
ci / checks (push) Successful in 6m33s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m26s
ci / release (push) Skipped
2026-11-06 16:49:11 +03:00
sanspie 9c02975882 engine: cache frame pacer capability
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 5m59s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 8m7s
ci / release (push) Skipped
2026-11-06 15:47:34 +03:00
sanspie 6a148e63e0 ci: pause releases and fix profiler lint
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 11m27s
ci / release (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 5m41s
2026-11-06 15:20:17 +03:00
sanspie 48d1eabef2 profiler: report actual FPS and idle waits
ci / checks (push) Failing after 5m21s
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 7m49s
ci / release (push) Skipped
2026-11-06 12:27:31 +03:00
56 changed files with 1588 additions and 127 deletions
+16 -1
View File
@@ -1,3 +1,18 @@
# 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.
Gitea is the source of truth. The Linux workflow runs there; optional Gitea
platform runners remain controlled by `PLATFORM_RUNNERS`.
`github-native.yml` is for the GitHub mirror. It builds and tests on native
Windows, macOS (arm64 and amd64), and Linux runners, and uploads unsigned CI
artifacts and diagnostic logs. The Windows installer smoke is restricted to an
ephemeral GitHub-hosted runner. macOS bundles are ad-hoc signed, not notarized.
These jobs are not evidence of platform support until they actually pass on
their respective runners.
Keep GitHub Actions disabled through the first mirror sync: historical tags
can contain older workflows. Before enabling Actions, verify mirrored refs and
restrict the allowed workflows so historical release jobs cannot run. Use a
separate repository-scoped credential for the mirror; do not put it in a URL.
Automatic nightly and stable publication is paused until a separately reviewed
release pipeline enforces the necessary gates and preserves existing releases.
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
# 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'
if: github.server_url != 'https://github.com' && vars.PLATFORM_RUNNERS == 'true'
strategy:
fail-fast: false
matrix:
+5 -1
View File
@@ -20,6 +20,7 @@ env:
jobs:
checks:
if: github.server_url != 'https://github.com'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -44,6 +45,7 @@ jobs:
run: make nsis-check
build:
if: github.server_url != 'https://github.com'
strategy:
fail-fast: false
matrix:
@@ -121,7 +123,9 @@ jobs:
release:
needs: [checks, build]
if: github.event_name == 'push'
# Pause all automatic releases until the reviewed nightly/stable pipeline
# enforces native gates and preserves existing tags and release assets.
if: ${{ false }}
runs-on: ubuntu-24.04
permissions:
contents: write
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
//go:build aego_gltest
package edit
import (
"runtime"
"testing"
"aego/asset"
"aego/core/log"
"aego/core/mathx"
"aego/editor/core/document"
"aego/modules/render2d"
"aego/scene"
"aego/scene/frame"
)
func TestSceneView3DShows2DSpritePixels(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
p := newProject(t)
const path = "scenes/2d-in-3d.aescene"
writeScene(t, p, path, func(s *scene.Scene) {
h := s.Create(s.Root(), "Red sprite")
sr := scene.Add[render2d.SpriteRenderer](s, h)
*sr = render2d.DefaultSpriteRenderer()
sr.Sprite = asset.NewRef[asset.Texture](asset.BuiltinWhite)
sr.Tint = mathx.Color{R: 1, A: 1}
sr.Size = mathx.V2(4, 4)
})
s, err := Open(p, Config{Width: 800, Height: 600, Hidden: true, NoWatch: true, Logger: log.Nop()})
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.Step()
ed := s.App.Context()
doc, err := ed.Docs.Open(path)
if err != nil {
t.Fatal(err)
}
doc.View.Mode = document.View3D
doc.View.Orbit = document.Orbit{Distance: 10, FOV: 60}
ed.Settings.Grid = false
for range 3 {
s.Step()
}
raw, ok := s.App.SceneView().LightingWorld().ServiceAny(frame.ServiceID)
if !ok {
t.Fatal("missing Scene View runner")
}
runner := raw.(*frame.Runner)
readRed := func() int {
t.Helper()
for range 2 {
s.Step()
}
target, w, h := runner.Target()
pixels := make([]byte, w*h*4)
if target.IsZero() {
t.Fatal("no Scene View target")
}
if err := ed.Renderer.Device().ReadPixels(target, 0, 0, w, h, pixels); err != nil {
t.Fatal(err)
}
count := 0
for i := 0; i+3 < len(pixels); i += 4 {
if pixels[i] > 160 && pixels[i+1] < 80 && pixels[i+2] < 80 {
count++
}
}
return count
}
visible := readRed()
if visible < 100 {
t.Fatalf("2D sprite produced only %d red pixels in 3D Scene View", visible)
}
sc := doc.Scene()
h := sc.Find(sc.Root(), "Red sprite")
sc.SetEnabled(h, false)
if hidden := readRed(); hidden >= visible/2 {
t.Fatalf("disabling the sprite left %d of %d red pixels", hidden, visible)
}
}
+2 -1
View File
@@ -29,7 +29,8 @@ func WriteChrome(w io.Writer, t *Tracer, frames []Frame) error {
bw.WriteString(`,{"name":"frame ` + u(f.Index) + `","ph":"X","pid":1,"tid":1,"ts":` + us(f.Start) +
`,"dur":` + us(f.Dur) + `,"args":{"gc":` + u(uint64(f.GC)) +
`,"dropped":` + u(uint64(f.Dropped)) + `,"world":` + u(f.World) + `,"frameId":` + u(f.FrameID) +
`,"firstTick":` + u(f.FirstTick) + `,"lastTick":` + u(f.LastTick) + `}}`)
`,"firstTick":` + u(f.FirstTick) + `,"lastTick":` + u(f.LastTick) +
`,"paceWaitNs":` + strconv.FormatInt(f.PaceWaitNS, 10) + `,"idleWaitNs":` + strconv.FormatInt(f.IdleWaitNS, 10) + `}}`)
for _, e := range f.Events {
name := `""`
if int(e.Zone) < len(names) {
+56
View File
@@ -0,0 +1,56 @@
package trace
import (
"bytes"
"encoding/json"
"testing"
"time"
)
func TestPaceWaitMetadataSurvivesOverflowAndResets(t *testing.T) {
tr := New(Options{Frames: 2, Events: 1})
tr.SetEnabled(true)
zone := tr.Zone("test")
tr.BeginFrame()
tr.Begin(zone).End()
tr.Begin(zone).End() // No room for the pacing event itself.
tr.AddPaceWait(10*time.Millisecond, false)
tr.AddPaceWait(60*time.Millisecond, true)
tr.AddPaceWait(-time.Second, true)
tr.EndFrame()
f := tr.Frame(0)
if f.Dropped != 1 || f.PaceWaitNS != int64(70*time.Millisecond) || f.IdleWaitNS != int64(60*time.Millisecond) {
t.Fatalf("dropped=%d pace=%d idle=%d", f.Dropped, f.PaceWaitNS, f.IdleWaitNS)
}
snapshot := tr.Snapshot(1)
for range 2 {
tr.BeginFrame()
tr.EndFrame()
}
if f = tr.Frame(0); f.PaceWaitNS != 0 || f.IdleWaitNS != 0 {
t.Fatalf("reused frame retained wait: %+v", f)
}
if snapshot[0].PaceWaitNS != int64(70*time.Millisecond) || snapshot[0].IdleWaitNS != int64(60*time.Millisecond) {
t.Fatal("snapshot lost pacing metadata")
}
var out bytes.Buffer
if err := WriteChrome(&out, tr, snapshot); err != nil {
t.Fatal(err)
}
var exported struct {
Events []struct {
Args struct {
PaceWaitNS int64 `json:"paceWaitNs"`
IdleWaitNS int64 `json:"idleWaitNs"`
} `json:"args"`
} `json:"traceEvents"`
}
if err := json.Unmarshal(out.Bytes(), &exported); err != nil {
t.Fatal(err)
}
if len(exported.Events) < 2 || exported.Events[1].Args.PaceWaitNS != int64(70*time.Millisecond) || exported.Events[1].Args.IdleWaitNS != int64(60*time.Millisecond) {
t.Fatalf("exported pacing metadata: %+v", exported.Events)
}
var nilTracer *Tracer
nilTracer.AddPaceWait(time.Second, true)
}
+25 -10
View File
@@ -24,16 +24,19 @@ type Event struct {
}
type Frame struct {
Index uint64
Start int64
Dur int64
GC uint32
Dropped uint32
World uint64
FrameID uint64
FirstTick uint64
LastTick uint64
Events []Event
Index uint64
Start int64
Dur int64
// PaceWaitNS is deliberate frame-limiter sleep; IdleWaitNS is its idle-mode part.
PaceWaitNS int64
IdleWaitNS int64
GC uint32
Dropped uint32
World uint64
FrameID uint64
FirstTick uint64
LastTick uint64
Events []Event
}
func (f *Frame) Duration() time.Duration {
@@ -158,6 +161,7 @@ func (t *Tracer) BeginFrame() {
t.seq++
f := &t.frames[t.head]
f.Index, f.Start, f.Dur, f.GC, f.Dropped = t.seq, t.now(), 0, 0, 0
f.PaceWaitNS, f.IdleWaitNS = 0, 0
f.World, f.FrameID, f.FirstTick, f.LastTick = 0, 0, 0, 0
f.Events = f.Events[:0]
t.cur, t.depth = f, 0
@@ -171,6 +175,17 @@ func (t *Tracer) Mark(world, frame, first, last uint64) {
f.World, f.FrameID, f.FirstTick, f.LastTick = world, frame, first, last
}
// AddPaceWait records intentional pacing independently of the event capacity.
func (t *Tracer) AddPaceWait(d time.Duration, idle bool) {
if t == nil || t.cur == nil || d <= 0 {
return
}
t.cur.PaceWaitNS += int64(d)
if idle {
t.cur.IdleWaitNS += int64(d)
}
}
func (t *Tracer) EndFrame() {
if t == nil || t.cur == nil {
return
+18 -3
View File
@@ -135,8 +135,10 @@ type Desc struct {
Checked func() bool
Enabled func() bool
Run func() error
nameKey string
tipKey string
// SearchRun optionally opens an item rather than toggling it from Search Everywhere.
SearchRun func() error
nameKey string
tipKey string
}
// Info adds an icon and a tooltip to a command registered elsewhere.
@@ -312,6 +314,15 @@ func (r *Registry) Enabled(id string) bool {
}
func (r *Registry) Run(id string) error {
return r.run(id, false)
}
// RunFromSearch uses the search-specific action when one is registered.
func (r *Registry) RunFromSearch(id string) error {
return r.run(id, true)
}
func (r *Registry) run(id string, search bool) error {
d, ok := r.byID[id]
if !ok {
return errs.New(errs.EditorCommand, "unknown command", log.F("command", id))
@@ -319,7 +330,11 @@ func (r *Registry) Run(id string) error {
if d.Enabled != nil && !d.Enabled() {
return errs.New(errs.EditorCommand, "command is disabled", log.F("command", id))
}
err := d.Run()
run := d.Run
if search && d.SearchRun != nil {
run = d.SearchRun
}
err := run()
if err != nil && r.OnError != nil {
r.OnError(id, err)
}
+36
View File
@@ -1,11 +1,47 @@
package command
import (
"errors"
"testing"
"aego/platform/input"
)
func TestRunFromSearchUsesOptionalAction(t *testing.T) {
r := New()
var normal, search int
failure := errors.New("search failed")
var reported error
r.OnError = func(id string, err error) { reported = err }
if err := r.Register(Desc{ID: "window.example", Run: func() error {
normal++
return nil
}, SearchRun: func() error {
search++
return failure
}}); err != nil {
t.Fatal(err)
}
if err := r.Run("window.example"); err != nil || normal != 1 || search != 0 {
t.Fatalf("normal action: %v, normal %d, search %d", err, normal, search)
}
if err := r.RunFromSearch("window.example"); !errors.Is(err, failure) || !errors.Is(reported, failure) || search != 1 {
t.Fatalf("search action: %v, reported %v, ran %d", err, reported, search)
}
if err := r.Register(Desc{ID: "ordinary", Run: func() error { normal++; return nil }}); err != nil {
t.Fatal(err)
}
if err := r.RunFromSearch("ordinary"); err != nil || normal != 2 {
t.Fatalf("ordinary action from search: %v, ran %d", err, normal)
}
if err := r.Register(Desc{ID: "disabled", Enabled: func() bool { return false }, Run: func() error { return nil }, SearchRun: func() error { search++; return nil }}); err != nil {
t.Fatal(err)
}
if err := r.RunFromSearch("disabled"); err == nil || search != 1 {
t.Fatal("search must not run a disabled command")
}
}
func TestAltShortcutDispatches(t *testing.T) {
r := New()
ran := 0
+15 -3
View File
@@ -234,19 +234,31 @@ func (p *Panel) folderStack(dir string, img mathx.Rect) {
it := &p.items[list[k]]
lift := float32(k%2) * icon * 0.06
cr := mathx.Rect{X: x0 + float32(k)*step, Y: at.Y + icon*0.02 - lift, W: cs, H: cs}
ui.Rect(cr, th.CardBg)
in := mathx.Rect{X: cr.X + 2, Y: cr.Y + 2, W: cr.W - 4, H: cr.H * 0.42}
fill, border := folderSheetColors(th)
ui.Rect(cr, fill)
// The front of the folder covers the lower part of each sheet. Use
// the visible portion for the preview instead of a thin strip.
in := mathx.Rect{X: cr.X + 2, Y: cr.Y + 2, W: cr.W - 4, H: max(1, cr.H*0.72-2)}
if !p.thumb(it, in) {
ic := cs * 0.4
widgets.IconAt(ui, mathx.Rect{X: cr.X + (cs-ic)/2, Y: cr.Y + 2, W: ic, H: ic}, kindIcon(it.Kind), p.kindColor(it.Kind))
}
ui.Outline(cr, th.Border)
ui.Outline(cr, border)
}
front := mathx.Rect{X: at.X + icon*0.07, Y: at.Y + icon*0.43, W: icon * 0.86, H: icon * 0.43}
ui.RoundRect(front, icon*0.035, th.Accent)
ui.Rect(mathx.Rect{X: front.X, Y: front.Y, W: front.W, H: max(1, icon*0.025)}, back)
}
func folderSheetColors(th *core.Theme) (mathx.Color, mathx.Color) {
if th.Dark {
return th.CardBg, th.Border
}
border := th.TextDim
border.A = 0.55
return th.WidgetDown, border
}
func (p *Panel) code(lines []string, r mathx.Rect) {
ui := p.f.UI
th := &ui.Theme
+13
View File
@@ -7,6 +7,7 @@ import (
"aego/asset/pipeline"
"aego/core/ids"
"aego/editor/core/code/highlight"
"aego/ui/theme"
)
func TestDeclName(t *testing.T) {
@@ -30,6 +31,18 @@ func TestDeclName(t *testing.T) {
}
}
func TestLightFolderSheetsContrastWithPanel(t *testing.T) {
light, dark := theme.Light(), theme.Editor()
fill, border := folderSheetColors(&light)
if theme.Contrast(fill, light.Panel) < 1.2 || theme.Contrast(border, fill) < 1.5 {
t.Fatal("light folder sheets must be distinguishable from the panel and each other")
}
fill, border = folderSheetColors(&dark)
if fill != dark.CardBg || border != dark.Border {
t.Fatal("dark folder sheet colors changed")
}
}
func TestFolderStackIncludesScriptsAndData(t *testing.T) {
p := New(Deps{})
p.items = []pipeline.AssetInfo{
+82
View File
@@ -0,0 +1,82 @@
package profiler
import (
"fmt"
"math"
"testing"
"time"
"aego/core/stats"
"aego/core/trace"
"aego/editor/core/context"
"aego/editor/core/play"
"aego/render"
)
func TestFPSUsesMeasuredFrameIntervals(t *testing.T) {
for _, fps := range []float64{7.3, 15, 60} {
for interval, seconds := range intervals() {
t.Run(fmt.Sprintf("%.1f_interval_%d_%gs", fps, interval, seconds), func(t *testing.T) {
p := New(Deps{})
p.interval = interval
// Simulated frame time deliberately differs from wall-clock sampling.
f := &stats.Frame{FrameTime: 1.0 / 144}
for i := 0; i < 1000 && p.n < 2; i++ {
p.Record(100+float64(i)/fps, f, render.Stats{}, play.Stats{}, context.ScriptStats{}, false, false)
if p.n > 0 {
got := p.at(p.n - 1).fps()
if math.Abs(got-fps) > 0.00001 {
t.Fatalf("measured %.5f FPS, want %.5f", got, fps)
}
}
}
if p.n < 2 {
t.Fatalf("completed %d buckets, want at least two", p.n)
}
})
}
}
}
func TestFPSDoesNotCountProfilerPauseAsOneFrame(t *testing.T) {
p := New(Deps{})
f := &stats.Frame{FrameTime: 0.1}
record := func(now float64) {
p.Record(now, f, render.Stats{}, play.Stats{}, context.ScriptStats{}, false, false)
}
for _, now := range []float64{10, 10.1, 10.2, 10.3} {
record(now)
}
p.paused = true
record(20)
p.paused = false
for _, now := range []float64{30, 30.1, 30.2, 30.3} {
record(now)
}
if p.n < 2 || math.Abs(p.at(p.n-1).fps()-10) > 0.00001 {
t.Fatalf("resumed FPS: buckets=%d fps=%v", p.n, p.at(p.n-1).fps())
}
}
func TestFrameRatingExcludesOnlyPacingWait(t *testing.T) {
ms := func(n int64) int64 { return n * int64(time.Millisecond) }
for _, tc := range []struct {
name string
frame trace.Frame
want frameRating
}{
{"idle", trace.Frame{Dur: ms(67), PaceWaitNS: ms(65), IdleWaitNS: ms(65)}, frameIdle},
{"idle work within idle budget", trace.Frame{Dur: ms(100), PaceWaitNS: ms(60), IdleWaitNS: ms(60)}, frameIdle},
{"idle real work warning", trace.Frame{Dur: ms(130), PaceWaitNS: ms(60), IdleWaitNS: ms(60)}, frameWarn},
{"idle real work error", trace.Frame{Dur: ms(210), PaceWaitNS: ms(60), IdleWaitNS: ms(60)}, frameError},
{"unexplained slow frame", trace.Frame{Dur: ms(67)}, frameError},
{"normal pacing", trace.Frame{Dur: ms(17), PaceWaitNS: ms(15)}, frameOK},
{"out of range wait is clamped", trace.Frame{Dur: ms(5), PaceWaitNS: ms(7)}, frameOK},
} {
t.Run(tc.name, func(t *testing.T) {
if got := rateFrame(&tc.frame); got != tc.want {
t.Fatalf("rating %v, want %v", got, tc.want)
}
})
}
}
+29 -5
View File
@@ -2,6 +2,7 @@ package profiler
import (
"fmt"
"math"
"runtime/metrics"
"strconv"
"strings"
@@ -43,9 +44,18 @@ const (
)
type bucket struct {
avg [metricCount]float32
peak [metricCount]float32
frames int
avg [metricCount]float32
peak [metricCount]float32
frames int
intervals int
elapsed float64
}
func (b *bucket) fps() float64 {
if b.elapsed <= 0 {
return 0
}
return float64(b.intervals) / b.elapsed
}
type mark struct {
@@ -60,6 +70,8 @@ type Panel struct {
valid [metricCount]int
acc bucket
start float64
lastSample float64
haveSample bool
interval int
paused bool
playing bool
@@ -267,6 +279,7 @@ func (p *Panel) Record(now float64, f *stats.Frame, r render.Stats, g play.Stats
p.hasScripts = false
}
if p.paused || f == nil {
p.haveSample = false
return
}
p.materials = r
@@ -297,6 +310,14 @@ func (p *Panel) Record(now float64, f *stats.Frame, r render.Stats, g play.Stats
if p.acc.frames == 0 {
p.start = now
}
if p.haveSample {
dt := now - p.lastSample
if dt > 0 && !math.IsInf(dt, 0) {
p.acc.elapsed += dt
p.acc.intervals++
}
}
p.lastSample, p.haveSample = now, true
for i := range s {
p.acc.avg[i] += s[i]
p.acc.peak[i] = max(p.acc.peak[i], s[i])
@@ -473,11 +494,14 @@ func (p *Panel) toolbar(ui *core.Context, bar mathx.Rect) {
if p.n == 0 {
return
}
fps := float64(p.at(p.n-1).frames) / p.seconds()
fps := "—"
if v := p.at(p.n - 1).fps(); v > 0 {
fps = strconv.FormatFloat(v, 'f', 1, 64)
}
span := strconv.FormatFloat(p.seconds()*history, 'f', 0, 64)
info := mathx.Rect{X: bar.X + 176, Y: bar.Y, W: max(0, bar.W-176), H: bar.H}
ui.PushClip(info)
ui.Text(i18n.Tr("editor.profiler.fps_v_window_span_s", "fps {v} window {span} s", i18n.Str("v", strconv.Itoa(int(fps+0.5))), i18n.Str("span", span)), mathx.Vec2{X: info.X, Y: ui.TextY(bar)}, th.Text)
ui.Text(i18n.Tr("editor.profiler.fps_v_window_span_s", "fps {v} window {span} s", i18n.Str("v", fps), i18n.Str("span", span)), mathx.Vec2{X: info.X, Y: ui.TextY(bar)}, th.Text)
ui.PopClip()
}
+38 -3
View File
@@ -32,6 +32,34 @@ func nsMS(ns int64) string {
return strconv.FormatFloat(float64(ns)/1e6, 'f', 2, 64) + " ms"
}
type frameRating uint8
const (
frameOK frameRating = iota
frameIdle
frameWarn
frameError
)
// Rate the work against the active target: idle deliberately targets 15 FPS.
func rateFrame(f *trace.Frame) frameRating {
work := max(0, f.Dur-max(0, f.PaceWaitNS))
budget := int64(budgetNS)
if f.IdleWaitNS > 0 {
budget *= 4
}
switch {
case work > 2*budget:
return frameError
case work > budget:
return frameWarn
case f.IdleWaitNS > 0:
return frameIdle
default:
return frameOK
}
}
func (tl *Timeline) Draw(ui *core.Context, d Deps, r mathx.Rect) {
th := &ui.Theme
tr := d.Tracer
@@ -58,6 +86,11 @@ func (tl *Timeline) Draw(ui *core.Context, d Deps, r mathx.Rect) {
return
}
info := i18n.Tr("editor.profiler.frame_formatuint_dur", "Frame {formatuint} {dur}", i18n.Str("formatuint", strconv.FormatUint(f.Index, 10)), i18n.Str("dur", nsMS(f.Dur)))
if f.IdleWaitNS > 0 {
info += i18n.Tr("editor.profiler.idle_wait_duration", " idle wait {duration}", i18n.Str("duration", nsMS(f.IdleWaitNS)))
} else if f.PaceWaitNS > 0 {
info += i18n.Tr("editor.profiler.frame_wait_duration", " frame limit wait {duration}", i18n.Str("duration", nsMS(f.PaceWaitNS)))
}
if f.GC > 0 {
info += " GC " + strconv.Itoa(int(f.GC))
}
@@ -88,11 +121,13 @@ func (tl *Timeline) strip(ui *core.Context, d Deps, tr *trace.Tracer, r mathx.Re
f := tr.Frame(back)
h := min(float32(f.Dur)/stripScaleNS, 1) * r.H
col := th.Ok
switch {
case f.Dur > 2*budgetNS:
switch rateFrame(f) {
case frameError:
col = th.Error
case f.Dur > budgetNS:
case frameWarn:
col = th.Warn
case frameIdle:
col = th.TextDim
}
if back == tl.back && tl.paused {
col = th.Accent
+2 -2
View File
@@ -68,7 +68,7 @@ func (p *Panel) drawFixedBackdrop(f *render.ViewFrame) {
func (p *Panel) drawOverlays(s *scene.Scene, dbg *debug.Draw, th *core.Theme) {
win := p.d.Project.Window
col := th.ViewportText
col := th.SceneText
var presentationWorld *engine.World
if p.cur != nil {
presentationWorld = p.cur.w
@@ -106,7 +106,7 @@ func (p *Panel) drawGizmos(s *scene.Scene, dbg *debug.Draw, th *core.Theme) {
Colors: gizmo.Colors{
X: th.AxisX,
Y: th.AxisY,
Center: th.ViewportLine,
Center: th.SceneLine,
Rotate: th.Accent,
Hot: th.AxisHot,
Field: th.FieldGizmo,
+4 -3
View File
@@ -8,9 +8,9 @@ import (
)
func theme3(th *core.Theme) Theme3 {
grid := th.ViewportText
grid := th.SceneText
grid.A = 0.25
cam := th.ViewportText
cam := th.SceneText
cam.A = 0.7
box := th.Accent
box.A = 0.8
@@ -19,7 +19,7 @@ func theme3(th *core.Theme) Theme3 {
Camera: cam,
Box: box,
Grid: grid,
Gizmo: gizmo.Colors3{X: th.AxisX, Y: th.AxisY, Z: th.AxisZ, Center: th.ViewportLine, Hot: th.AxisHot},
Gizmo: gizmo.Colors3{X: th.AxisX, Y: th.AxisY, Z: th.AxisZ, Center: th.SceneLine, Hot: th.AxisHot},
}
}
@@ -45,6 +45,7 @@ func (p *Panel) render3D() {
sv.DoClear = false
sv.Label = "sceneview3d.scene"
wd.runner.SetPooledTarget(t)
wd.runner.SetPreview2DIn3D(true)
wd.runner.SetFixedView(sv)
wd.runner.SetHidden(p.runnerHide())
p.d.Viewport.RenderWorld(wd.w)
+13 -7
View File
@@ -199,17 +199,19 @@ func (o *Overlay) collect() {
o.items = append(o.items, Item{
Label: d.Title(), Detail: detail, Kind: "action", Tab: TabActions, Icon: "tool.select",
Fav: favs.Item{Kind: favs.Command, Command: id, Label: d.Name}, HasFav: true,
Run: func() {
if o.d.Commands.Enabled(id) {
o.d.Commands.Run(id)
} else {
o.d.Notices.Notify(context.LevelInfo, i18n.Note("editor.search.this_action_is_not_available", "This action is not available right now"))
}
},
Run: func() { o.runCommand(id) },
})
}
}
func (o *Overlay) runCommand(id string) {
if o.d.Commands.Enabled(id) {
o.d.Commands.RunFromSearch(id)
} else {
o.d.Notices.Notify(context.LevelInfo, i18n.Note("editor.search.this_action_is_not_available", "This action is not available right now"))
}
}
func (o *Overlay) rank() {
o.hits = o.hits[:0]
q := strings.ReplaceAll(strings.TrimSpace(o.query), " ", "")
@@ -419,6 +421,10 @@ func (o *Overlay) favorites() {
o.items = append(o.items, Item{
Label: label, Detail: detail, Kind: "favorite", Tab: TabAll, Icon: "ui.star", Fav: it, HasFav: true,
Run: func() {
if it.Kind == favs.Command {
o.runCommand(it.Command)
return
}
if err := favs.Open(env, it); err != nil {
o.d.Notices.Notify(context.LevelInfo, err.Error())
}
+15 -3
View File
@@ -249,6 +249,7 @@ type App struct {
problemRev uint64
clip *clipboard
picker scenePicker
captures []sceneCapture
// updateTask shows a running download in the Task Center; rollbackSaid keeps the rollback notice to one.
updateTask *tasks.Task
rollbackSaid bool
@@ -1087,7 +1088,6 @@ func (a *App) Update(dt float64) {
}
func (a *App) Render(float64) {
defer a.pace()
a.ctx.RunRenderHooks()
if a.list == nil || a.list.Empty() {
return
@@ -1139,16 +1139,28 @@ func (a *App) idle() bool {
len(a.modals) == 0
}
func (a *App) pace() {
// PaceFrame waits outside the render system so pacing is not measured as rendering.
func (a *App) PaceFrame() {
if !a.opt.Paced {
return
}
target := time.Second / 60
if a.idle() {
idle := a.idle()
if idle {
target = time.Second / 15
}
if el := time.Since(a.lastFrame); el < target {
name := engine.ZoneFrameWait
if idle {
name = engine.ZoneIdleWait
}
tr := a.eng.Trace()
wait := tr.Begin(tr.Zone(name))
start := time.Now()
time.Sleep(target - el)
d := time.Since(start)
wait.End()
tr.AddPaceWait(d, idle)
}
a.lastFrame = time.Now()
}
+23 -3
View File
@@ -16,17 +16,37 @@ import (
const captureFlip = true
type sceneCapture struct {
tex gpu.TextureHandle
w, h int
name string
}
// Capture is requested during rendering; readback is only valid after EndFrame.
func (a *App) captureTexture(tex gpu.TextureHandle, w, h int, name string) {
a.captures = append(a.captures, sceneCapture{tex: tex, w: w, h: h, name: name})
}
// FrameFinished reads the target before the next UI update can resize it.
func (a *App) FrameFinished() {
for _, c := range a.captures {
a.saveCapture(c)
}
clear(a.captures)
a.captures = a.captures[:0]
}
func (a *App) saveCapture(c sceneCapture) {
dev := a.ctx.Engine.GPU()
if dev == nil {
return
}
buf := make([]byte, w*h*4)
if err := dev.ReadPixels(tex, 0, 0, w, h, buf); err != nil {
buf := make([]byte, c.w*c.h*4)
if err := dev.ReadPixels(c.tex, 0, 0, c.w, c.h, buf); err != nil {
a.ctx.Notify(context.LevelError, i18n.Note("editor.shell.cannot_read_the_scene_view", "cannot read the Scene View: {error}", i18n.Str("error", err.Error())))
return
}
rel, err := capture.Save(a.opt.Project.Root, name, w, h, buf, captureFlip, time.Now())
rel, err := capture.Save(a.opt.Project.Root, c.name, c.w, c.h, buf, captureFlip, time.Now())
if err != nil {
a.ctx.Notify(context.LevelError, err.Error())
return
+4
View File
@@ -306,6 +306,10 @@ func (a *App) registerWindowCommands() error {
a.ws.Toggle(id)
return nil
},
SearchRun: func() error {
a.ws.Focus(id)
return nil
},
}); err != nil {
return err
}
+3 -1
View File
@@ -154,7 +154,9 @@ func (a *App) drawStatus(r mathx.Rect) {
item(i18n.Tr("editor.shell.n_unsaved", "{n} unsaved", i18n.Str("n", strconv.Itoa(n))), th.Warn, "", false)
}
mid := mathx.Rect{X: left + 8, Y: r.Y, W: max(0, right-left-16), H: r.H}
text, col := a.contextHint(), th.TextDisabled
// Context hints are useful text, not disabled controls; keep them readable
// on the status bar in both themes.
text, col := a.contextHint(), th.TextDim
if n, ok := a.ctx.LastNotice(); ok && ui.Time-n.Time < 8 {
text, col = n.Text, th.TextDim
switch n.Level {
+54
View File
@@ -0,0 +1,54 @@
package shell
import (
"testing"
"time"
"aego/core/log"
"aego/editor/core/context"
"aego/editor/core/play"
"aego/editor/core/tasks"
"aego/editor/core/workspace"
"aego/engine"
"aego/ui/core"
"aego/ui/dnd"
)
func TestPaceFrameRecordsIdleAndActiveWait(t *testing.T) {
e, err := engine.NewBuilder(engine.Config{Headless: true, Trace: true, ResRoot: t.TempDir(), UserRoot: t.TempDir()}).WithLogger(log.Nop()).Build()
if err != nil {
t.Fatal(err)
}
defer e.Close()
ctx := &context.Editor{DnD: &dnd.State{}}
a := &App{opt: Options{Paced: true}, eng: e, ctx: ctx, play: play.New(ctx), ws: workspace.New("", nil), tasks: tasks.New(tasks.Deps{}), ui: core.New(nil)}
for _, tc := range []struct {
name string
idle bool
zone string
minimum time.Duration
}{
{"idle", true, engine.ZoneIdleWait, time.Second / 15},
{"active", false, engine.ZoneFrameWait, time.Second / 60},
} {
t.Run(tc.name, func(t *testing.T) {
a.lastInput = e.Platform().Now()
if tc.idle {
a.lastInput -= 2
}
// Leave extra margin so scheduler delays before the call don't skip the wait.
a.lastFrame = time.Now().Add(100 * time.Millisecond)
tr := e.Trace()
tr.BeginFrame()
a.PaceFrame()
tr.EndFrame()
f := tr.Frame(0)
if f.PaceWaitNS < int64(tc.minimum) || (f.IdleWaitNS > 0) != tc.idle {
t.Fatalf("pace wait=%d idle wait=%d", f.PaceWaitNS, f.IdleWaitNS)
}
if len(f.Events) != 1 || tr.Name(f.Events[0].Zone) != tc.zone {
t.Fatalf("wait trace: %+v", f.Events)
}
})
}
}
+14
View File
@@ -6,6 +6,8 @@ const (
SystemAppFixed = "app.fixed"
SystemAppUpdate = "app.update"
SystemAppRender = "app.render"
ZoneFrameWait = "app.frame_wait"
ZoneIdleWait = "app.idle_wait"
)
type App interface {
@@ -17,6 +19,18 @@ type App interface {
Shutdown()
}
// FramePacer is an optional app capability invoked once after the render phase.
// Deliberate waits belong here, outside the render system's trace and render time.
type FramePacer interface {
PaceFrame()
}
// FrameFinisher runs on the main thread after GPU EndFrame and before swap.
// It is not called on frames that do not render.
type FrameFinisher interface {
FrameFinished()
}
type appSystemKind uint8
const (
+2
View File
@@ -42,6 +42,8 @@ type Engine struct {
main *World
wctx Context
app App
framePacer FramePacer
finisher FrameFinisher
onEvent func(platform.Event)
vsync platform.VSyncMode
frameLimit float64
+41
View File
@@ -0,0 +1,41 @@
package engine
import (
"testing"
"aego/gpu"
"aego/platform"
)
type finishProbe struct {
testApp
texture gpu.TextureHandle
calls int
readErr error
}
func (a *finishProbe) Init(e *Engine) error {
a.testApp.Init(e)
var err error
a.texture, err = e.GPU().CreateTexture(gpu.TextureDesc{Width: 1, Height: 1, Format: gpu.TexRGBA8})
return err
}
func (a *finishProbe) FrameFinished() {
a.calls++
a.readErr = a.e.GPU().ReadPixels(a.texture, 0, 0, 1, 1, make([]byte, 4))
}
func TestFrameFinisherRunsAfterEndFrameAndNotWhenMinimized(t *testing.T) {
e, plat := newTestEngine(t)
defer e.Close()
a := &finishProbe{}
if !e.Step(a) || a.calls != 1 || a.readErr != nil {
t.Fatalf("finish calls=%d, readback error=%v", a.calls, a.readErr)
}
plat.Push(platform.Event{Kind: platform.EvWindowMinimized})
e.Step(a)
if a.calls != 1 {
t.Fatal("finisher ran for a minimized frame")
}
}
+6
View File
@@ -4537,6 +4537,9 @@ messages version=1 lang=en {
msg "editor.profiler.frame_took_formatfloat_ms_for" {
text="Frame took {formatfloat} ms for 3 frames in a row"
}
msg "editor.profiler.frame_wait_duration" {
text=" frame limit wait {duration}"
}
msg "editor.profiler.game" {
text="Game"
}
@@ -4588,6 +4591,9 @@ messages version=1 lang=en {
msg "editor.profiler.graph.update" {
text="update"
}
msg "editor.profiler.idle_wait_duration" {
text=" idle wait {duration}"
}
msg "editor.profiler.instances_instances_update_update_fixed" {
text="instances {instances} update {update} fixed {fixed} faults {faults} budget aborts {formatuint} heap objects {heapobjects} GC {formatuint2} / {gctime} ms"
}
+6
View File
@@ -4027,6 +4027,9 @@ messages version=1 lang=ru {
msg "editor.profiler.frame_took_formatfloat_ms_for" {
text="Кадр занял {formatfloat} мс три кадра подряд"
}
msg "editor.profiler.frame_wait_duration" {
text=" ожидание ограничителя {duration}"
}
msg "editor.profiler.game" {
text="Игра"
}
@@ -4078,6 +4081,9 @@ messages version=1 lang=ru {
msg "editor.profiler.graph.update" {
text="обновление"
}
msg "editor.profiler.idle_wait_duration" {
text=" ожидание простоя {duration}"
}
msg "editor.profiler.instances_instances_update_update_fixed" {
text="экземпляров {instances} update {update} fixed {fixed} сбоев {faults} прерываний по бюджету {formatuint} объектов в куче {heapobjects} GC {formatuint2} / {gctime} мс"
}
+16 -2
View File
@@ -23,6 +23,10 @@ func (e *Engine) Step(app App) bool {
e.err = errs.Wrap(err, errs.EngineInit, "app initialization failed")
return false
}
// Type-assertion cache updates can allocate; resolve this once before
// the per-frame hot path, even for apps without a pacer.
e.framePacer, _ = app.(FramePacer)
e.finisher, _ = app.(FrameFinisher)
e.appInit = true
e.lastTime = e.plat.Now()
}
@@ -297,17 +301,27 @@ func (e *Engine) frame() {
e.stats.UpdateTime = renderStart - updateStart
e.stats.ResetGPU()
if !e.minimized && e.metrics.PixelW > 0 && e.metrics.PixelH > 0 {
rendered := !e.minimized && e.metrics.PixelW > 0 && e.metrics.PixelH > 0
paceTime := 0.0
if rendered {
e.exec(&e.ctx, PhaseRenderPrepare, mask, plan.Paused)
am = e.allocMark()
e.curFrame = e.dev.BeginFrame(e.metrics.PixelW, e.metrics.PixelH)
e.allocSince(zoneGPU, am)
e.exec(&e.ctx, PhaseRender, mask, plan.Paused)
if e.framePacer != nil {
start := e.plat.Now()
e.framePacer.PaceFrame()
paceTime = max(0, e.plat.Now()-start)
}
e.exec(&e.ctx, PhaseRenderUI, mask, plan.Paused)
gs := e.curFrame.Stats()
am = e.allocMark()
e.dev.EndFrame()
e.curFrame = nil
if e.finisher != nil {
e.finisher.FrameFinished()
}
if e.capture != nil {
e.grab()
}
@@ -322,7 +336,7 @@ func (e *Engine) frame() {
} else if e.minimized {
time.Sleep(idleSleep)
}
e.stats.RenderTime = e.plat.Now() - renderStart
e.stats.RenderTime = max(0, e.plat.Now()-renderStart-paceTime)
if lim := e.frameLimit; lim > 0 && e.vsync == platform.VSyncOff && !e.cfg.Headless {
if wait := 1/lim - (e.plat.Now() - now); wait > 0 {
+80
View File
@@ -0,0 +1,80 @@
package engine
import (
"testing"
"time"
"aego/core/log"
"aego/platform"
"aego/platform/headless"
)
type paceProbe struct {
testApp
clock *resumableClock
calls int
}
func (a *paceProbe) PaceFrame() {
a.calls++
tr := a.e.Trace()
s := tr.Begin(tr.Zone(ZoneIdleWait))
a.clock.now += 0.070
s.End()
tr.AddPaceWait(70*time.Millisecond, true)
}
func TestPacerIsOutsideRenderAndNotCalledWhenMinimized(t *testing.T) {
p := &resumableClock{Platform: headless.New(headless.Options{})}
cfg := Config{Headless: true, Trace: true, ResRoot: t.TempDir(), UserRoot: t.TempDir()}
e, err := NewBuilder(cfg).WithLogger(log.Nop()).WithPlatform(p).Build()
if err != nil {
t.Fatal(err)
}
defer e.Close()
a := &paceProbe{clock: p}
if !e.Step(a) {
t.Fatal("first step failed")
}
if e.framePacer != a {
t.Fatal("frame pacer was not resolved during application initialization")
}
f := e.Trace().Frame(0)
var renderEnd, swapStart, waitStart, waitEnd uint32
for _, ev := range f.Events {
switch e.Trace().Name(ev.Zone) {
case SystemAppRender:
renderEnd = ev.Start + ev.Dur
case "engine.swap":
swapStart = ev.Start
case ZoneIdleWait:
if ev.Depth != 0 {
t.Fatalf("wait is nested in a render zone: %+v", ev)
}
waitStart = ev.Start
waitEnd = ev.Start + ev.Dur
}
}
if a.calls != 1 || a.renders != 1 || renderEnd == 0 || swapStart == 0 || waitStart < renderEnd || waitEnd > swapStart {
t.Fatalf("calls=%d renders=%d render end=%d wait=%d..%d swap start=%d", a.calls, a.renders, renderEnd, waitStart, waitEnd, swapStart)
}
if e.Stats().RenderTime != 0 || f.PaceWaitNS != int64(70*time.Millisecond) || f.IdleWaitNS != int64(70*time.Millisecond) {
t.Fatalf("render=%v wait=%d idle=%d", e.Stats().RenderTime, f.PaceWaitNS, f.IdleWaitNS)
}
p.Push(platform.Event{Kind: platform.EvWindowMinimized})
e.Step(a)
if a.calls != 1 || a.renders != 1 {
t.Fatalf("pacing or rendering ran while minimized: calls=%d renders=%d", a.calls, a.renders)
}
}
func TestFramePacerAbsentWithoutCapability(t *testing.T) {
e, _ := newTestEngine(t)
defer e.Close()
if !e.Step(&testApp{}) {
t.Fatal("first step failed")
}
if e.framePacer != nil {
t.Fatal("application without a pacer unexpectedly acquired one")
}
}
+16
View File
@@ -0,0 +1,16 @@
---
id: credits
icon: doc.tutorial
group: start
title: Credits and acknowledgements
order: 100
---
# Credits and acknowledgements
Thank you to the artists, contributors and communities whose work inspires Aego projects.
## Music
- **Glorious Morning** — Waterflame. [Track link supplied for the project](https://new.akarpov.ru/media/music/glorious-morning-waterflame.mp3).
The track remains the property of its rights holders. This acknowledgement and link do not grant permission to distribute or use the recording; confirm the applicable licence before including it in a project or release.
+13
View File
@@ -0,0 +1,13 @@
---
id: credits
title: Благодарности и авторы
---
# Благодарности и авторы
Спасибо художникам, авторам и сообществам, чьи работы вдохновляют проекты Aego.
## Музыка
- **Glorious Morning** — Waterflame. [Ссылка на трек, предоставленная для проекта](https://new.akarpov.ru/media/music/glorious-morning-waterflame.mp3).
Права на запись принадлежат её правообладателям. Это упоминание и ссылка сами по себе не дают разрешения на использование или распространение; перед включением записи в проект или релиз проверьте применимую лицензию.
+44
View File
@@ -0,0 +1,44 @@
---
id: platformer-effects
icon: doc.tutorial
group: start
title: Platformer lighting and effects
order: 4
---
# Platformer lighting and effects
This guide explores the finished `examples/platformer` project. To build the basic game from scratch, start with [Platformer from scratch](ref:platformer).
## Lighting and surface assets
Open `examples/platformer` to inspect the complete lighting example. The level, collisions and controls remain the gameplay example; surface assets and lights make normal mapping, shadows and emission visible in that same level. F6 toggles lighting for the view. F7 cycles the lighting, normal and shadow views. Both keys are registered Input Actions and do not change materials or simulation state.
| Existing asset | Surface association |
|---|---|
| `assets/surfaces/platform.png` | Its explicit Height source is `platform_height.png`, with import-time normal generation enabled. This generated normal is shared by the level's platforms. |
| `ground.png` and `wall.png` | Explicit normal maps take priority; their height sources remain linked for authoring. |
| `runner.png` | Each of the eight original animation frames has its own normal-frame association in `runner_n.png`. The source silhouette, frame size and gameplay origin are preserved. |
| `lamp.png` | `lamp_em.png` is an emission mask. It brightens the lamp's core using `lamp_lit.aemat`; the nearby Point light illuminates other objects. The mask does not create Bloom. |
| `window_cookie.png` | A linear cookie mask shapes the Sprite light without using the albedo atlas coordinates. |
To edit the platform's surface, open `platform.png` from Assets and follow **Height source** to `platform_height.png`. Enable **Lit surface** and change the height strength in that shared height document. The generated-normal candidate and movable preview light show the result before Save. Press Play, keep the image tab open, and use **Save settings**: the running level receives the new prepared normal generation. Undo restores the earlier appearance while leaving the metadata document dirty and the saved file unchanged; Redo restores the saved result. Stop returns to the unchanged EditScene. An explicit Normal assignment overrides the Height-generated normal.
Open `runner.png`, select an existing frame and use the preview's Flip X/Y controls to check the paired normal. These controls affect only the preview; the player script sets FlipX when changing direction. Source pixels are edited in an external image tool, while channel links and import settings use the shared asset document and its Undo history.
The player and enemy use separate `LightOccluder2D` polygons for their shadows. These approximate the head and legs of the shared runner sprite without changing the player's capsule or the enemy's box collider. The outlines are static: they do not follow individual animation frames.
See [Image and Sprite authoring](ref:render2d/image-viewer) and [Materials and shaders](ref:render2d/materials) for role and color-space rules, missing-reference diagnostics and the lighting model. Native and web exports use this same project and include its normal, height-derived, emission and cookie artifacts. Preparing the site's demo does not publish it.
## Dust and sparks
Jump and keep moving after landing. Dust starts at the player's feet and stays where it was emitted. Select `Player/Dust` to change `rectSize` and `lifetime`. In `scripts/player/player.aes`, `OnLanded` emits eight particles, `OnJumped` five and `OnClimbed` six. `playOnStart` is off and `rate` is zero, so these events trigger the bursts. `Player/Slash/Sparks` emits on the attack animation's Hit event.
`space=world` fixes each particle's birth transform. Use `space=local` for an effect that should travel with its parent. To compare the level with and without particles, change **Particle Effects** in the game's accessibility settings. Leave F6 and F7 unchanged: they control lighting.
At `StartLantern`, select the `Smoke` or `Embers` child to inspect the two continuous emitters. Use the Inspector preview to pause the effect, lock its seed and compare changes before Play. Under **Over Life**, drag a gradient key, release it and use Undo to restore the previous color transition. See [CPU particle effects](ref:particles) for presets and the runnable example.
## Camera post-processing
Open the platformer in Game View and select **Camera → Post Process**. Bloom is enabled with Threshold 0.75, Intensity 0.22 and Radius 6. Watch the halo around the lantern while changing Intensity. Vignette, Color Grading and the Cold custom material are in the stack but start disabled. Enable one to compare its effect. Open Settings during Play and compare the opaque menu with the component on and off. To restore the original world image, disable Post Process. F6 and F7 control lighting and diagnostics.
Center is relative to the camera output; (0.5, 0.5) is its centre. Intensity controls darkening, while Softness controls the transition width. The boundary is circular in physical viewport pixels even on a wide display. See [post-processing](ref:render2d.PostProcess) for a runnable example.
+41
View File
@@ -0,0 +1,41 @@
---
id: platformer-effects
title: Свет и эффекты платформера
---
# Свет и эффекты платформера
Здесь разобран готовый проект `examples/platformer`. Чтобы сначала собрать базовую игру, откройте [Платформер с нуля](ref:platformer).
## Освещение и карты поверхностей
Откройте `examples/platformer`, чтобы увидеть полный пример освещения. Уровень, коллизии и управление сохраняют игровой сценарий; карты поверхностей и источники показывают нормали, тени и эмиссию. F6 переключает освещение вида, F7 — обычный вид, нормали и тени. Обе клавиши зарегистрированы как действия ввода и не меняют материалы или состояние симуляции.
| Ассет проекта | Связь с картой поверхности |
|---|---|
| `assets/surfaces/platform.png` | Источник высоты — `platform_height.png`, у которого включена генерация нормалей при импорте. Полученная карта общая для платформ уровня. |
| `ground.png` и `wall.png` | Явные карты нормалей имеют приоритет; исходные карты высоты также связаны с ними для авторинга. |
| `runner.png` | У каждого из восьми кадров есть связь с normal-кадром в `runner_n.png`. Силуэт, размер и игровая точка отсчёта сохранены. |
| `lamp.png` | `lamp_em.png` — маска эмиссии. Она осветляет сердцевину лампы через `lamp_lit.aemat`; соседний Point light освещает другие объекты. Маска не создаёт Bloom. |
| `window_cookie.png` | Линейная cookie-маска задаёт форму Sprite light, не используя координаты атласа альбедо. |
Чтобы изменить поверхность платформы, откройте `platform.png` из Assets и перейдите по **источнику высоты** к `platform_height.png`. Включите **освещённую поверхность** и измените силу высоты в общем документе карты. Кандидат normal map и перемещаемый источник предпросмотра показывают результат до сохранения. Включите Play, оставьте вкладку изображения открытой и нажмите **Сохранить настройки**: работающий уровень получит новое подготовленное поколение нормалей. Undo возвращает прежний вид, оставляя документ метаданных изменённым, а сохранённый файл — прежним; Redo возвращает сохранённый результат. Stop возвращает неизменённую EditScene. Явная Normal-карта имеет приоритет над нормалью, полученной из Height.
Откройте `runner.png`, выберите существующий кадр и используйте Flip X/Y предпросмотра, чтобы проверить связанную normal map. Эти переключатели действуют только на предпросмотр; игровой скрипт задаёт FlipX при смене направления. Пиксели исходного изображения редактируются во внешнем графическом редакторе, связи каналов и настройки импорта — в общем документе ассета с историей Undo.
У игрока и врага для теней заданы отдельные полигоны `LightOccluder2D`. Они приблизительно повторяют голову и ноги общего спрайта, не меняя коллайдеры. Контуры статичны и не подстраиваются под кадры анимации.
Правила ролей и цветового пространства, диагностика отсутствующих ссылок и модель освещения описаны в разделах [Просмотр изображений и спрайтов](ref:render2d/image-viewer) и [Материалы и шейдеры](ref:render2d/materials). Native- и web-экспорты используют тот же проект и включают нормали, результат преобразования высоты, эмиссию и cookie. Подготовка демо сайта сама по себе не публикует его.
## Пыль и искры
Прыгните и после приземления продолжайте бежать. Пыль появляется у ног и остаётся над местом возникновения. Выберите `Player/Dust`, чтобы изменить `rectSize` и `lifetime`. В `scripts/player/player.aes` обработчик `OnLanded` выпускает восемь частиц, `OnJumped` — пять, `OnClimbed` — шесть. У эмиттера выключен `playOnStart`, а `rate` равен нулю, поэтому частицы появляются по этим событиям. `Player/Slash/Sparks` выпускает искры по событию Hit анимации атаки.
При `space=world` движение эмиттера не сдвигает уже родившиеся частицы. Выберите `space=local`, если эффект должен двигаться вместе с родителем. Чтобы сравнить уровень с частицами и без них, переключите **Эффекты частиц** в настройках специальных возможностей игры. F6 и F7 управляют освещением; при сравнении оставьте их в одном положении.
У `StartLantern` выберите дочерний узел `Smoke` или `Embers`: это независимые источники дыма и искр. В предпросмотре Inspector можно поставить эффект на паузу, закрепить зерно и сравнить изменения до запуска игры. В разделе **По ходу жизни** перетащите ключ градиента и нажмите Undo, чтобы восстановить переход. Пресеты и работающий пример собраны в разделе [Эффекты частиц на CPU](ref:particles).
## Постобработка камеры
Откройте платформер в Game View и выберите **Camera → Post Process**. В сцене включён Bloom с Threshold 0,75, Intensity 0,22 и Radius 6. Меняйте Intensity и наблюдайте за ореолом вокруг лампы. В стеке также есть выключенные Vignette, Color Grading и пользовательский материал Cold. Включите один из них для сравнения. Во время Play откройте настройки и сравните непрозрачную часть меню при включённом и выключенном компоненте. Чтобы вернуть исходное изображение мира, выключите Post Process. F6 и F7 управляют освещением и диагностикой.
Центр задаётся относительно выхода камеры: (0.5, 0.5) — середина. Интенсивность задаёт степень затемнения, мягкость — ширину перехода. Радиус измеряется относительно короткой стороны изображения: на широком экране окружность не превращается в овал. Работающий пример приведён в разделе [Постобработка](ref:render2d.PostProcess).
+3 -36
View File
@@ -13,11 +13,11 @@ Start with [Your first project](ref:quickstart) if you have not opened the edito
The finished example is a separate, more detailed project at `examples/platformer`. Open it with `aego edit examples/platformer` and load its main scene to inspect the game used by the website demo. Move with A/D or the arrow keys, jump with Space, attack with J and open settings with Esc. F6 toggles lighting and F7 cycles its debug views.
In the Hierarchy, inspect the player's Dust and Sparks emitters and the StartLantern's Smoke and Embers. The landing and attack events trigger bursts. The camera's Bloom adds a screen-space halo, while the lantern's Light2D illuminates nearby Lit surfaces and Light Occluder 2D contours cast shadows. See [particle effects](ref:particles), [materials and lighting](ref:render2d/materials) and [post-processing](ref:render2d.PostProcess) for editable examples and Try It scenes. This guide below starts from simpler shapes rather than reproducing every asset in the finished project.
This guide starts from simpler shapes rather than reproducing every asset in the finished project. For its lighting, surface maps, particles and post-processing, see [Platformer lighting and effects](ref:platformer-effects).
## 1. Floor and player
Create a project from the **2D** template. In the Hierarchy, create an empty node and name it `Floor`. Give it these components in the Inspector:
Create a project from the **2D** template. In the Hierarchy, create an empty node and name it **Floor**. Give it these components in the Inspector:
- **Sprite Renderer**, Size `30, 1`, a grey Tint;
- **Rigid Body 2D**, Type `static`;
@@ -651,38 +651,5 @@ scene version=1 {
- [Recipes](ref:recipes): double jump, one-way platforms, moving platforms, dash and more.
- [Gameplay kit](ref:gameplay): every gameplay component in one place.
- [Animation](ref:animation): sprite animation and state machines for the player.
- [Platformer lighting and effects](ref:platformer-effects): surface maps, lights, particles and camera post-processing in the finished example.
- `examples/platformer` in the engine source is this game grown further: animation, sound, stomping, saves, settings and 2D lighting.
## Lighting and surface assets in the example
Open the existing `examples/platformer` project to inspect the complete lighting example. Its level, collisions and controls remain the gameplay example; the added surface assets and lights make normal mapping, shadows and emission visible in that same level. F6 toggles lighting for the view. F7 cycles the lighting, normal and shadow views. Both keys are registered Input Actions and do not change materials or simulation state.
| Existing asset | Surface association |
|---|---|
| `assets/surfaces/platform.png` | Its explicit Height source is `platform_height.png`, with import-time normal generation enabled. This generated normal is shared by the level's platforms. |
| `ground.png` and `wall.png` | Explicit normal maps take priority; their height sources remain linked for authoring. |
| `runner.png` | Each of the eight original animation frames has its own stable normal-frame association in `runner_n.png`. The source silhouette, frame size and gameplay origin are preserved. |
| `lamp.png` | `lamp_em.png` is an emission mask. It brightens the lamp's core using `lamp_lit.aemat`; the nearby Point light illuminates other objects. The mask does not create Bloom. |
| `window_cookie.png` | A linear cookie mask shapes the Sprite light without using the albedo atlas coordinates. |
To edit the existing platform's surface, open `platform.png` from Assets and follow **Height source** to `platform_height.png`. Enable **Lit surface** and change the height strength in that shared height document. The generated-normal candidate and movable preview light show the result before Save. Press Play, keep the image tab open, and use **Save settings**: the running level receives the new prepared normal generation. Undo restores the earlier appearance while leaving the metadata document dirty and the saved file unchanged; Redo restores the saved result. Stop returns to the unchanged EditScene. An explicit Normal assignment would override this Height-generated normal.
Open `runner.png`, select an existing frame and use the preview's Flip X/Y controls to check the paired normal. These controls affect only the preview; the actual player script sets FlipX when changing direction. The source image and frame pixels are edited in an external image tool, while channel links and import settings use the shared asset document and its Undo history.
The player and enemy use separate `LightOccluder2D` polygons for their shadows. These approximate the head and legs of the shared runner sprite without changing the player's capsule or the enemy's box collider. The outlines are static: they do not follow individual animation frames.
See [Image and Sprite authoring](ref:render2d/image-viewer) and [Materials and shaders](ref:render2d/materials) for role/color-space rules, missing-reference diagnostics and the lighting model. Native and web exports use this same project and include its normal, height-derived, emission and cookie artifacts. The site's demo is prepared from this export; preparation alone does not publish a new site version.
## Dust and sparks in the example
Open `examples/platformer`, jump and keep moving after landing. The dust starts at the player's feet and stays over the patch of floor where it was emitted. Select `Player/Dust` to change the effect: `rectSize` sets the width and depth of the emitting strip, and `lifetime` sets how long each puff lasts. In `scripts/player/player.aes`, `OnLanded` emits eight particles, `OnJumped` five and `OnClimbed` six. `playOnStart` is off and `rate` is zero, so the emitter produces particles only on these events. `Player/Slash/Sparks` emits a burst on the attack animation's Hit event.
`space=world` fixes each particle's birth transform. Use `space=local` for an effect that should travel with its parent. To compare the same level with and without particles, change **Particle Effects** in the game's accessibility settings. Leave F6 and F7 unchanged: they control lighting.
At `StartLantern`, select the `Smoke` or `Embers` child to inspect the two continuous emitters. Use the Inspector preview to pause the effect, lock its seed and compare changes before Play. Under **Over Life**, drag a gradient key, release it and use Undo to restore the previous color transition. See [CPU particle effects](ref:particles) for the five presets and the runnable example.
## Camera PostFX in the example game
Open the platformer in Game View and select **Camera → Post Process**. Bloom is enabled with Threshold 0.75, Intensity 0.22 and Radius 6. Watch the halo around the lantern while changing Intensity. Vignette, Color Grading and the Cold custom material are already in the stack and start disabled. Enable one to compare its effect. Open Settings during Play and compare the opaque menu with the component on and off. To restore the original world image, disable Post Process. F6 and F7 control lighting and its diagnostics.
Center is relative to the camera output; (0.5, 0.5) is its centre. Intensity controls darkening, while Softness controls the width of the transition. The boundary is circular in physical viewport pixels even on a wide display.
+3 -36
View File
@@ -10,11 +10,11 @@ title: Платформер с нуля
Готовый пример — отдельный, более подробный проект `examples/platformer`. Откройте его командой `aego edit examples/platformer` и загрузите основную сцену, чтобы изучить игру из демо на сайте. A/D или стрелки двигают персонажа, пробел — прыжок, J — атака, Esc открывает настройки. F6 включает и выключает освещение, F7 переключает его режимы отладки.
В иерархии изучите эмиттеры Dust и Sparks игрока, а также Smoke и Embers у StartLantern. События приземления и атаки запускают всплески частиц. Bloom на камере создаёт ореол на экране, а Light2D фонаря освещает соседние поверхности Lit; контуры Light Occluder 2D отбрасывают тени. Редактируемые примеры и сцены Try It находятся в разделах [частицы](ref:particles), [материалы и свет](ref:render2d/materials) и [постобработка](ref:render2d.PostProcess). Руководство ниже начинается с простых фигур, а не повторяет все ассеты готового проекта.
Руководство ниже начинается с простых фигур, а не повторяет все ассеты готового проекта. Его освещение, карты поверхностей, частицы и постобработка описаны отдельно: [Свет и эффекты платформера](ref:platformer-effects).
## 1. Пол и игрок
Создайте проект из шаблона **2D**. В «Иерархии» создайте пустой узел и назовите его `Floor`. Добавьте ему в «Инспекторе» такие компоненты:
Создайте проект из шаблона **2D**. В «Иерархии» создайте пустой узел и назовите его **Floor**. Добавьте ему в «Инспекторе» такие компоненты:
- **«Отрисовка спрайта»**, «Размер» `30, 1`, серый «Оттенок»;
- **«Тело 2D»**, «Тип» `static`;
@@ -648,38 +648,5 @@ scene version=1 {
- [Рецепты](ref:recipes): двойной прыжок, односторонние платформы, движущиеся платформы, рывок и многое другое.
- [Набор геймплея](ref:gameplay): все компоненты геймплея в одном месте.
- [Анимация](ref:animation): анимация спрайтов и машины состояний для игрока.
- [Свет и эффекты платформера](ref:platformer-effects): карты поверхностей, источники света, частицы и постобработка готового примера.
- `examples/platformer` в исходниках движка — эта же игра, развитая дальше: анимация, звук, прыжки на врагов, сохранения, настройки и 2D-освещение.
## Освещение и карты поверхностей в примере
Откройте существующий проект `examples/platformer`, чтобы увидеть полный пример освещения. Уровень, коллизии и управление сохраняют игровой сценарий; добавленные карты поверхностей и источники показывают нормали, тени и эмиссию в том же уровне. F6 переключает освещение вида. F7 переключает обычный вид, нормали и тени. Обе клавиши зарегистрированы как действия ввода и не меняют материалы или состояние симуляции.
| Ассет проекта | Связь с картой поверхности |
|---|---|
| `assets/surfaces/platform.png` | Явный источник высоты — `platform_height.png`, у которого включена генерация нормалей при импорте. Полученная normal map общая для платформ уровня. |
| `ground.png` и `wall.png` | Явные normal maps имеют приоритет; исходные карты высоты также связаны с ними для авторинга. |
| `runner.png` | У каждого из восьми исходных кадров анимации есть собственная стабильная связь с normal-кадром в `runner_n.png`. Исходный силуэт, размер кадра и игровая точка отсчёта сохранены. |
| `lamp.png` | `lamp_em.png` — маска эмиссии. Она осветляет сердцевину лампы через `lamp_lit.aemat`; соседний Point light освещает другие объекты. Маска не создаёт Bloom. |
| `window_cookie.png` | Линейная cookie-маска задаёт форму Sprite light, не используя координаты атласа альбедо. |
Чтобы изменить поверхность существующей платформы, откройте `platform.png` из Assets и перейдите по **источнику высоты** к `platform_height.png`. Включите **освещённую поверхность** и измените силу высоты в общем документе этой карты. Кандидат normal map и перемещаемый источник предпросмотра показывают результат до сохранения. Включите Play, оставьте вкладку изображения открытой и нажмите **Сохранить настройки**: работающий уровень получит новое подготовленное поколение нормалей. Undo возвращает прежний вид, оставляя документ метаданных изменённым, а сохранённый файл — прежним; Redo возвращает сохранённый результат. Stop возвращает неизменённую EditScene. Явно назначенная Normal-карта имела бы приоритет над нормалью, полученной из Height.
Откройте `runner.png`, выберите существующий кадр и используйте Flip X/Y предпросмотра, чтобы проверить связанную normal map. Эти переключатели действуют только на предпросмотр; игровой скрипт сам задаёт FlipX при смене направления. Пиксели исходного изображения редактируются во внешнем графическом редакторе, а связи каналов и настройки импорта — в общем документе ассета с его историей Undo.
У игрока и врага для теней заданы отдельные полигоны `LightOccluder2D`. Они приблизительно повторяют голову и ноги общего спрайта, не меняя капсулу игрока и прямоугольный коллайдер врага. Контуры статичны и не подстраиваются под отдельные кадры анимации.
Правила ролей и цветового пространства, диагностика отсутствующих ссылок и модель освещения описаны в разделах [Просмотр изображений и спрайтов](ref:render2d/image-viewer) и [Материалы и шейдеры](ref:render2d/materials). Native- и web-экспорты используют этот же проект и включают нормали, результат преобразования высоты, эмиссию и cookie. Демо сайта подготавливается из этого экспорта; подготовка сама по себе не публикует новую версию сайта.
## Пыль и искры в примере
Откройте `examples/platformer`, прыгните и после приземления продолжайте бежать. Пыль появляется у ног персонажа и остаётся над местом, где она возникла. Чтобы настроить эффект, выберите `Player/Dust`: `rectSize` задаёт размеры узкой полосы под ногами, а `lifetime` — время жизни пылинки. В `scripts/player/player.aes` обработчик `OnLanded` выпускает восемь частиц, `OnJumped` — пять, а `OnClimbed` — шесть. У эмиттера выключен `playOnStart`, а `rate` равен нулю, поэтому частицы появляются только при этих событиях. `Player/Slash/Sparks` выпускает искры по событию Hit в анимации атаки.
При `space=world` движение эмиттера не сдвигает уже родившиеся частицы. Выберите `space=local`, если эффект должен двигаться вместе с родителем. Чтобы сравнить этот же уровень с частицами и без них, переключите **Эффекты частиц** в настройках специальных возможностей игры. F6 и F7 управляют освещением; при сравнении оставьте их в одном положении.
У фонаря `StartLantern` выберите дочерний узел `Smoke` или `Embers`: это два независимых источника дыма и искр. В предпросмотре Inspector можно поставить эффект на паузу, закрепить зерно и сравнить изменения до запуска игры. В разделе **По ходу жизни** перетащите ключ градиента, отпустите мышь и нажмите Undo, чтобы восстановить прежний цветовой переход. Пять пресетов и работающий пример собраны в разделе [Эффекты частиц на CPU](ref:particles).
## Постобработка на камере готового платформера
Откройте платформер в Game View и выберите **Camera → Post Process**. В сцене включён Bloom с Threshold 0,75, Intensity 0,22 и Radius 6. Меняйте Intensity и наблюдайте за ореолом вокруг лампы. В стеке также есть выключенные Vignette, Color Grading и пользовательский материал Cold. Включите один из них для сравнения. Во время Play откройте настройки и сравните непрозрачную часть меню при включённом и выключенном компоненте. Чтобы вернуть исходное изображение мира, выключите Post Process. F6 и F7 управляют освещением и его диагностикой.
Центр задаётся относительно выхода камеры: (0.5, 0.5) — середина. Интенсивность задаёт степень затемнения, мягкость — ширину перехода. Радиус измеряется относительно короткой стороны изображения: на широком экране окружность не превращается в овал.
+36
View File
@@ -0,0 +1,36 @@
package render2d
import (
"testing"
"aego/core/mathx"
"aego/scene/frame"
)
func Test2DSpritesIn3DEditorPreviewOnly(t *testing.T) {
f := lightingTestWorld(t)
h := f.sprite("player", 1)
f.s.SetPosition(h, mathx.Vec2{X: 3, Y: 4})
f.s.SetScale(h, mathx.Vec2{X: 2, Y: 3})
f.r.Prepare(640, 480, 1)
var b frame.Bucket
collect3(f.r, &b, 1)
if len(b.Sprites3D) != 0 {
t.Fatal("ordinary 2D sprites must not change runtime 3D camera output")
}
f.r.SetPreview2DIn3D(true)
collect3(f.r, &b, 1)
if len(b.Sprites3D) != 1 {
t.Fatalf("editor preview sprites = %d, want 1", len(b.Sprites3D))
}
got := b.Sprites3D[0].World
want := mathx.Mat4Lift(f.s.RenderWorld(h))
if got != want || got.Translation() != (mathx.Vec3{X: 3, Y: -4}) {
t.Fatalf("preview transform = %v, want %v", got, want)
}
b.Sprites3D = nil
collect3(f.r, &b, 2)
if len(b.Sprites3D) != 0 {
t.Fatal("3D preview ignored the view's layer mask")
}
}
+9 -2
View File
@@ -142,7 +142,8 @@ func collect(r *frame.Runner, b *frame.Bucket, mask uint64, ls *lightingState) {
func collect3(r *frame.Runner, b *frame.Bucket, mask uint64) {
sc, res, st := r.Scene(), r.Resolver(), r.Counters()
scene.Query1[SpriteRenderer](sc).Each(func(h scene.NodeHandle, sr *SpriteRenderer) {
if !sc.Under3D(h) || sc.Layers(h)&mask == 0 || r.Hidden(h) {
under3D := sc.Under3D(h)
if (!under3D && !r.Preview2DIn3D()) || sc.Layers(h)&mask == 0 || r.Hidden(h) {
return
}
region, ok := sr.Sprite.Region(res)
@@ -150,9 +151,15 @@ func collect3(r *frame.Runner, b *frame.Bucket, mask uint64) {
st.MissingAssets++
return
}
world := sc.World4(h)
if !under3D {
// Mat4Lift converts the Y-down 2D presentation transform to the
// Y-up 3D sprite basis, including its translation and rotation.
world = mathx.Mat4Lift(sc.RenderWorld(h))
}
b.Sprites3D = append(b.Sprites3D, sprite.Draw3D{
Region: region,
World: sc.World4(h),
World: world,
Size: Size(sr, region.Size()),
Origin: sr.Origin,
Tint: sr.Tint,
+7
View File
@@ -83,6 +83,7 @@ type Runner struct {
fixedBackdrop func(*render.ViewFrame)
fixed view.View
hasFixed bool
preview2DIn3D bool
captureView view.ID
factor float32
rscale float32
@@ -170,6 +171,12 @@ func (r *Runner) SetViewportSize(w, h int) {
// When coverage-free additive needs it, backdrop and world share a linear composite.
func (r *Runner) SetFixedBackdrop(draw func(*render.ViewFrame)) { r.fixedBackdrop = draw }
// SetPreview2DIn3D includes ordinary 2D sprites in a 3D editor preview. It
// does not change the scene or the behaviour of runtime cameras.
func (r *Runner) SetPreview2DIn3D(enabled bool) { r.preview2DIn3D = enabled }
func (r *Runner) Preview2DIn3D() bool { return r.preview2DIn3D }
func (r *Runner) SetFixedView(v view.View) {
r.fixed = v
r.hasFixed = true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+19 -1
View File
@@ -7,6 +7,7 @@ import (
"time"
"aego/app/agentui/backend"
agentpanel "aego/editor/panels/agent"
)
// A long answer stays cheap: 500 messages with Markdown, code and tool steps cost the frame no more than the panel's budget, and an idle frame allocates nothing (11b5a).
@@ -23,6 +24,15 @@ func TestAgentPanelBudgetOn500Messages(t *testing.T) {
steps = append(steps, backend.Step{Text: "done"})
s, _ := agentSession(t, &backend.Fake{Turns: [][]backend.Step{steps}})
panel := s.App.Agent()
activePanel := func() bool {
tree := s.App.Workspace().Tree
n, ok := tree.Node(tree.FindPanel(agentpanel.ID))
if !ok {
return false
}
id, ok := n.ActivePanel()
return ok && id == agentpanel.ID
}
frame := func(n int) time.Duration {
start := time.Now()
for range n {
@@ -33,6 +43,9 @@ func TestAgentPanelBudgetOn500Messages(t *testing.T) {
for range 30 {
s.Step()
}
if !activePanel() {
t.Fatal("the empty Agent panel is not the active tab")
}
empty := frame(60)
emptyAllocs := testing.AllocsPerRun(30, func() { s.Step() })
panel.Submit("explain the player")
@@ -40,13 +53,18 @@ func TestAgentPanelBudgetOn500Messages(t *testing.T) {
for range 30 {
s.Step()
}
if !activePanel() {
t.Fatal("the Agent transcript is not the active tab")
}
full := frame(120)
t.Logf("frame: %v with an empty panel, %v with 500 messages", empty, full)
if extra := full - empty; extra > 3*time.Millisecond {
t.Errorf("a 500-message transcript costs %v a frame over an empty panel (%v vs %v); the budget is 3 ms", extra, full, empty)
}
// a few allocations of noise are allowed; a transcript that allocates per message adds hundreds
if a := testing.AllocsPerRun(30, func() { s.Step() }); a > emptyAllocs+16 {
a := testing.AllocsPerRun(30, func() { s.Step() })
t.Logf("allocations per frame: %v empty, %v with 500 messages", emptyAllocs, a)
if a > emptyAllocs+16 {
t.Errorf("an idle frame allocates %v times with a 500-message transcript, %v with an empty panel", a, emptyAllocs)
}
}
+37
View File
@@ -0,0 +1,37 @@
package integration
import (
"image/png"
"os"
"path/filepath"
"testing"
"aego/app/agentui/backend"
"aego/editor/core/capture"
"aego/editor/panels/sceneview"
)
// Scene View screenshots must be read after the GPU has ended the frame.
func TestSceneViewCaptureAfterFrame(t *testing.T) {
s, root := agentSession(t, &backend.Fake{})
for range 3 {
s.Step()
}
if err := s.App.Context().Commands.Run(sceneview.CommandCapture); err != nil {
t.Fatal(err)
}
s.Step()
entries, err := os.ReadDir(filepath.Join(root, capture.Dir))
if err != nil || len(entries) != 1 {
t.Fatalf("Scene View screenshot: files=%v, err=%v", entries, err)
}
f, err := os.Open(filepath.Join(root, capture.Dir, entries[0].Name()))
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil || img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
t.Fatalf("invalid Scene View screenshot: %v", err)
}
}
@@ -0,0 +1,92 @@
package integration
import (
"testing"
"aego/app/edit"
"aego/editor/core/command"
"aego/editor/core/favorites"
"aego/editor/core/workspace"
"aego/platform/input"
"aego/ui/dock"
)
func searchWindow(t *testing.T, s *edit.Session, id string) {
t.Helper()
d, ok := s.App.Context().Commands.Get(command.WindowPrefix + id)
if !ok {
t.Fatalf("window command for %q is missing", id)
}
push(t, s, key(input.KeyLShift, input.ModShift)...)
push(t, s, key(input.KeyLShift, input.ModShift)...)
push(t, s, typed(d.Title())...)
push(t, s, key(input.KeyEnter, 0)...)
}
func TestSearchFavoriteOpensWindow(t *testing.T) {
s := workspaceSession(t)
id := workspace.Console
cmd := command.WindowPrefix + id
d, ok := s.App.Context().Commands.Get(cmd)
if !ok {
t.Fatal("console command is missing")
}
push(t, s, key(input.KeyLShift, input.ModShift)...)
push(t, s, key(input.KeyLShift, input.ModShift)...)
push(t, s, typed(d.Title())...)
push(t, s, key(input.KeyD, input.PrimaryMod())...)
push(t, s, key(input.KeyEscape, 0)...)
if !favorites.Load(s.Host.Project.Root).Has(favorites.Item{Kind: favorites.Command, Command: cmd}) {
t.Fatal("console was not added to favorites")
}
ws := s.App.Workspace()
ws.Show(id)
searchWindow(t, s, id)
requireActiveWindow(t, ws, id)
}
func requireActiveWindow(t *testing.T, ws *workspace.Workspace, id string) {
t.Helper()
n, ok := ws.Tree.Node(ws.Tree.FindPanel(id))
if !ok {
t.Fatalf("window %q is not open", id)
}
if active, ok := n.ActivePanel(); !ok || active != id {
t.Fatalf("active window = %q, want %q", active, id)
}
}
func TestSearchOpensWindowsInsteadOfToggling(t *testing.T) {
for _, id := range []string{workspace.Console, workspace.Profiler} {
t.Run(id, func(t *testing.T) {
s := workspaceSession(t)
ws := s.App.Workspace()
peer := workspace.Console
if id == peer {
peer = workspace.Profiler
}
// An open but inactive tab must be activated, not removed from the layout.
ws.Show(peer)
ws.Show(id)
ws.Tree.DockPanel(id, ws.Tree.FindPanel(peer), dock.SideCenter)
ws.Show(peer)
searchWindow(t, s, id)
requireActiveWindow(t, ws, id)
// Searching for the already active window must leave it open as well.
searchWindow(t, s, id)
requireActiveWindow(t, ws, id)
// The Window menu command remains a toggle.
if err := s.App.Context().Commands.Run(command.WindowPrefix + id); err != nil {
t.Fatal(err)
}
if ws.Tree.Has(id) {
t.Fatalf("window menu command did not close %q", id)
}
searchWindow(t, s, id)
requireActiveWindow(t, ws, id)
})
}
}
+83
View File
@@ -0,0 +1,83 @@
package modulecheck
import (
"os/exec"
"strings"
"testing"
)
// windowsPathComponent checks names in the Git tree, not the host filesystem:
// Windows cannot even check out a tree containing these names.
func windowsPathComponent(name string) bool {
if name == "" || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return false
}
if strings.ContainsAny(name, `<>:"\|?*`) {
return false
}
for _, c := range name {
if c < 32 {
return false
}
}
base, _, _ := strings.Cut(name, ".")
base = strings.ToUpper(base)
switch base {
case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$":
return false
}
if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9' {
return false
}
return true
}
func TestWindowsPathComponents(t *testing.T) {
for _, name := range []string{"aux.go", "CON", "lpt1.txt", "COM9", "bad.", "bad ", `bad:name`} {
if windowsPathComponent(name) {
t.Errorf("%q should not be portable", name)
}
}
for _, name := range []string{"auxiliary.go", "com10", "lpt0.txt", "console.go"} {
if !windowsPathComponent(name) {
t.Errorf("%q should be portable", name)
}
}
}
func TestGitTreeIsWindowsPortable(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
root := repoRoot(t)
probe := exec.Command("git", "rev-parse", "--is-inside-work-tree")
probe.Dir = root
if err := probe.Run(); err != nil {
t.Skip("not a Git checkout")
}
cmd := exec.Command("git", "ls-files", "-z")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
seen := make(map[string]string)
for _, path := range strings.Split(string(out), "\x00") {
if path == "" {
continue
}
parts := strings.Split(path, "/")
for i, part := range parts {
prefix := strings.Join(parts[:i+1], "/")
if !windowsPathComponent(part) {
t.Errorf("%s contains a Windows-incompatible name %q", path, part)
}
folded := strings.ToLower(prefix)
if other, ok := seen[folded]; ok && other != prefix {
t.Errorf("case-insensitive path collision: %s and %s", other, prefix)
} else {
seen[folded] = prefix
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
root="$PWD/.cache/github-ci"
mkdir -p "$root/bin"
exe="$root/bin/aego"
if [[ "$(go env GOOS)" == windows ]]; then exe="$exe.exe"; fi
go build -trimpath -o "$exe" ./cmd/aego
"$exe" version
"$exe" new "$root/project"
"$exe" import "$root/project"
"$exe" check "$root/project"
"$exe" run "$root/project" --headless --frames 60
"$exe" check examples/platformer
"$exe" run examples/platformer --headless --frames 120

Some files were not shown because too many files have changed in this diff Show More