Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d7cee7cc7 | ||
|
|
85d9efc67b | ||
|
|
ab6ea7ef58 | ||
|
|
9c02975882 | ||
|
|
6a148e63e0 | ||
|
|
48d1eabef2 |
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -27,6 +27,9 @@ type Frame struct {
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -135,6 +135,8 @@ type Desc struct {
|
||||
Checked func() bool
|
||||
Enabled func() bool
|
||||
Run func() error
|
||||
// SearchRun optionally opens an item rather than toggling it from Search Everywhere.
|
||||
SearchRun func() error
|
||||
nameKey string
|
||||
tipKey string
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package profiler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"runtime/metrics"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -46,6 +47,15 @@ type bucket struct {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -199,15 +199,17 @@ 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() {
|
||||
Run: func() { o.runCommand(id) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Overlay) runCommand(id string) {
|
||||
if o.d.Commands.Enabled(id) {
|
||||
o.d.Commands.Run(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() {
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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} мс"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
id: credits
|
||||
title: Благодарности и авторы
|
||||
---
|
||||
# Благодарности и авторы
|
||||
|
||||
Спасибо художникам, авторам и сообществам, чьи работы вдохновляют проекты Aego.
|
||||
|
||||
## Музыка
|
||||
|
||||
- **Glorious Morning** — Waterflame. [Ссылка на трек, предоставленная для проекта](https://new.akarpov.ru/media/music/glorious-morning-waterflame.mp3).
|
||||
|
||||
Права на запись принадлежат её правообладателям. Это упоминание и ссылка сами по себе не дают разрешения на использование или распространение; перед включением записи в проект или релиз проверьте применимую лицензию.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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) — середина. Интенсивность задаёт степень затемнения, мягкость — ширину перехода. Радиус измеряется относительно короткой стороны изображения: на широком экране окружность не превращается в овал.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||