Files
aego/runtime/player/player.go
T

535 lines
14 KiB
Go

package player
import (
"io"
"os"
"path/filepath"
"runtime/debug"
"strings"
"aego/app/assembly"
"aego/app/assembly/builtin"
"aego/app/crash"
"aego/asset"
"aego/asset/loader"
"aego/contracts/persistence"
"aego/core/errs"
"aego/core/ids"
"aego/core/log"
"aego/core/rng"
"aego/core/trace"
"aego/engine"
"aego/engine/base"
"aego/engine/module"
"aego/engine/problems"
"aego/engine/recorder"
"aego/engine/settings"
inputmod "aego/modules/input"
scriptsystems "aego/modules/scripting/systems"
"aego/platform"
"aego/render"
renderhost "aego/render/host"
"aego/runtime/bundle"
"aego/save"
"aego/scene"
"aego/scene/frame"
"aego/scene/sceneio"
"aego/script/bind"
scriptruntime "aego/script/runtime"
"aego/vfs"
"aego/vfs/exportfs"
"aego/vfs/osfs"
)
const (
inputService = "input.service"
WindowedMode = "windowed"
FullMode = "fullscreen"
)
type Config struct {
Data string
Profile string
Window string
Scene string
LogFile string
Trace string
Replay string
Headless bool
Hidden bool
Frames int
FrameTime float64
Debug bool
Seed uint64
NoProfile bool
Logger *log.Logger
Bundle *Bundle
}
type Player struct {
Data *bundle.Data
Engine *engine.Engine
Loader *loader.Loader
Renderer *render.Renderer
Types *scene.Types
Bindings *bind.Registry
Modules *module.Manifest
Sections *module.Sections
Settings *settings.Store
Problems *problems.Service
Scripts *scriptruntime.Runtime
Scene *scene.Scene
Runner *frame.Runner
Log *log.Logger
cfg Config
log *log.ChannelLogger
sceneID ids.AssetID
detach func()
unrec func()
ring *crash.Ring
profileDir string
profileErr error
crashDir string
crashScene bool
closers []func()
closed bool
}
type driver struct{}
func (driver) Init(*engine.Engine) error { return nil }
func (driver) Event(platform.Event) {}
func (driver) FixedUpdate(float64) {}
func (driver) Update(float64) {}
func (driver) Render(float64) {}
func (driver) Shutdown() {}
func Mount(dir string) (*bundle.Data, *vfs.VFS, error) {
f, err := osfs.Dir{Root: dir}.Open(bundle.ManifestFile)
if err != nil {
return nil, nil, errs.Wrap(err, errs.AssetNotMounted, "the game data has no manifest").With(log.F("dir", dir))
}
raw, err := io.ReadAll(f)
f.Close()
if err != nil {
return nil, nil, errs.Wrap(err, errs.VFSIO, "cannot read the game manifest").With(log.F("dir", dir))
}
d, err := bundle.DecodeData(raw, bundle.ManifestFile)
if err != nil {
return nil, nil, err
}
files := map[string]int64{}
prefix := bundle.Scheme + "://"
d.Index.Each(func(e *asset.Entry) {
for _, a := range e.Artifacts {
if rel, ok := strings.CutPrefix(a.Path, prefix); ok {
files[rel] = a.Size
}
}
})
v, err := vfs.New(vfs.Mount{Scheme: bundle.Scheme, Source: exportfs.New(dir, files), ReadOnly: true})
if err != nil {
return nil, nil, err
}
return d, v, nil
}
func Open(cfg Config, mods ...module.Module) (*Player, error) {
p := &Player{cfg: cfg, Runner: frame.New(), ring: crash.NewRing(500)}
if err := p.build(mods); err != nil {
p.Close()
return nil, err
}
return p, nil
}
func (p *Player) build(mods []module.Module) error {
data, files, err := p.mount()
if err != nil {
return err
}
p.Data = data
g := &data.Game
if !p.cfg.NoProfile {
p.profileDir, p.profileErr = platform.ProfileDir(g.Module, p.cfg.Profile)
if p.profileErr == nil && hostFiles {
p.crashDir = filepath.Join(p.profileDir, "crash")
}
}
if err := p.logger(); err != nil {
return err
}
p.log = p.Log.With(log.Engine)
ecfg := engine.Config{
Title: g.Window.Title,
Width: g.Window.Width,
Height: g.Window.Height,
Headless: p.cfg.Headless,
HeadlessFrameTime: p.cfg.FrameTime,
Hidden: p.cfg.Hidden,
Debug: p.cfg.Debug,
AppID: g.Module,
Trace: p.cfg.Trace != "",
Seed: p.cfg.Seed,
ResRoot: p.cfg.Data,
}
rmod := renderhost.New(render.Options{MaxSprites: 65536})
builder := engine.NewBuilder(ecfg).WithLogger(p.Log).Use(rmod)
if p.Types, err = assembly.NewTypes(); err != nil {
return err
}
p.Bindings = bind.New()
if err = frame.Register(builder.Registry()); err != nil {
return err
}
if p.Modules, err = builtin.Runtime().Load(p.Types, p.Bindings, assembly.Options{Seed: p.cfg.Seed}, mods...); err != nil {
return err
}
if p.Sections, err = p.Modules.LoadSections(g.Block, bundle.CoreBlocks...); err != nil {
return err
}
if ts, ok := module.SectionValue[base.Settings](p.Sections, base.Section); ok {
builder.Configure(ts.Apply)
p.crashScene = ts.CrashScene
}
builder.Use(p.Modules.Engine())
if err = p.Types.Schema().Freeze(); err != nil {
return err
}
if err = scriptsystems.Freeze(p.Bindings, p.Types.Schema()); err != nil {
return errs.Wrap(err, errs.EngineModule, "script bindings are invalid")
}
if p.Engine, err = builder.Build(); err != nil {
return err
}
p.Engine.Jobs().SetPanicHandler(func(rec any, stack []byte) { p.crash("panic", "worker", rec, stack, "") })
p.Renderer = rmod.Renderer()
p.Loader = loader.New(p.Engine.GPU(), p.Renderer.Textures(), p.Log)
if err = p.Loader.Mount(data.Index, files); err != nil {
return err
}
p.Engine.SetService(loader.ServiceID, p.Loader)
p.Engine.SetService(renderhost.ServiceID, p.Renderer)
p.Problems = problems.New()
p.Problems.Add(p.Modules.AssetProblems(p.Types.Schema(), p.Loader))
p.Engine.SetService(problems.ServiceID, p.Problems)
if p.Settings, err = p.Modules.NewSettings(); err != nil {
return err
}
p.Settings.SetNames(p.Sections.Names)
p.Engine.SetService(settings.ServiceID, p.Settings)
if err = p.Modules.Init(&module.InitContext{Engine: p.Engine, Assets: p.Loader, Log: p.Log, Sections: p.Sections, Problems: p.Problems, Settings: p.Settings}); err != nil {
return err
}
p.Settings.Freeze()
p.Settings.LoadProject(g.Defaults, bundle.ManifestFile)
p.loadProfile()
p.Problems.Add(p.Settings.Problems())
p.Settings.Start()
p.applyWindow()
for _, w := range p.Modules.Warnings {
p.log.Warn(w)
}
p.log.Info("game started", log.F("game", g.Name), log.F("version", g.Version), log.F("profile", data.Profile), log.F("data", p.cfg.Data))
return p.start()
}
func (p *Player) logger() error {
if p.cfg.Logger != nil {
p.Log = p.cfg.Logger
p.Log.AddSink(p.ring)
return nil
}
p.Log = log.New(log.FuncSink(func(log.Record) {}))
release := p.Data.Release()
if !release || p.cfg.Debug || !hostFiles {
p.Log.AddSink(log.NewTextSink(os.Stderr))
}
path := p.cfg.LogFile
if path == "" && release && p.profileDir != "" && hostFiles {
path = filepath.Join(p.profileDir, "logs", "game.log")
}
if path != "" {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return errs.Wrap(err, errs.VFSIO, "cannot create the log directory").With(log.F("path", path))
}
f, err := os.Create(path)
if err != nil {
return errs.Wrap(err, errs.VFSIO, "cannot create the log file").With(log.F("path", path))
}
p.Log.AddSink(log.NewTextSink(f))
p.closers = append(p.closers, func() { f.Close() })
}
level := log.Info
if p.cfg.Debug {
level = log.Debug
}
p.Log.SetLevel(level)
p.Log.AddSink(p.ring)
return nil
}
func (p *Player) loadProfile() {
if p.cfg.NoProfile {
return
}
if p.profileErr != nil {
p.log.Warn("player settings are not saved", log.F("err", p.profileErr.Error()))
return
}
path := filepath.Join(p.profileDir, platform.ProfileFile)
p.Engine.SetService(persistence.StoreID, save.NewStore(filepath.Join(p.profileDir, "saves")))
data, err := platform.ReadProfile(path)
if err != nil {
p.log.Warn("cannot read the player settings", log.F("path", path), log.F("err", err.Error()))
}
if lerr := p.Settings.LoadProfile(data, path); lerr != nil && len(data) > 0 {
_ = platform.WriteProfile(path+".bad", data)
p.log.Warn("the player settings are damaged, a copy is kept as settings.aetext.bad", log.F("path", path))
}
p.Settings.SetPersist(func(b []byte) error { return platform.WriteProfile(path, b) })
}
func (p *Player) applyWindow() {
if p.cfg.Window == "" {
return
}
d, ok := p.Engine.Window().(platform.Displayer)
if !ok {
return
}
mode := platform.Windowed
size := platform.DisplayMode{W: p.Data.Game.Window.Width, H: p.Data.Game.Window.Height}
if p.cfg.Window == FullMode {
mode, size = platform.Fullscreen, platform.DisplayMode{}
}
if err := d.SetWindowMode(mode, d.WindowDisplay(), size); err != nil {
p.log.Warn("cannot apply the window mode from the command line", log.F("mode", p.cfg.Window), log.F("err", err.Error()))
}
}
func (p *Player) start() error {
p.Engine.SetView(frame.ServiceID, p.Runner)
p.Scripts = scriptruntime.New(scriptruntime.Options{Bindings: p.Bindings, Schema: p.Types.Schema(), Log: p.Log, OnFault: p.fault, OnPanic: p.scriptPanic})
mods, err := Scripts(p.Loader)
if err != nil {
return err
}
if err := p.Scripts.Link(mods); err != nil {
return err
}
p.Engine.SetService(scriptruntime.ServiceID, p.Scripts)
p.Engine.SetView(persistence.HostID, p)
id, err := p.startScene()
if err != nil {
return err
}
p.sceneID = id
s, err := p.loadScene()
if err != nil {
return err
}
p.Scene = s
w := p.Engine.MainWorld()
w.Recorder = recorder.New(4096)
if p.cfg.Seed != 0 {
w.Random = rng.New(p.cfg.Seed)
}
p.bindScene()
if err := p.Scripts.Instantiate(p.Scene); err != nil {
return err
}
if err := p.attach(); err != nil {
return err
}
return p.replay()
}
func (p *Player) bindScene() {
if p.unrec != nil {
p.unrec()
}
p.unrec = frame.Record(p.Scene, p.Engine.MainWorld().Recorder)
p.Runner.Bind(p.Scene, p.Renderer, p.Loader)
p.Engine.SetService(scene.ServiceID, p.Scene)
}
func (p *Player) attach() error {
if p.detach != nil {
p.detach()
p.detach = nil
}
d, err := p.Modules.Attach(&module.AttachContext{World: p.Engine.MainWorld(), Scene: p.Scene, Log: p.Log, Play: true})
if err != nil {
return err
}
p.detach = d
return nil
}
func (p *Player) startScene() (ids.AssetID, error) {
s := strings.TrimSpace(p.cfg.Scene)
if s == "" {
if p.Data.Game.MainScene.IsZero() {
return ids.AssetID{}, errs.New(errs.AssetNotFound, "the game has no start scene")
}
return p.Data.Game.MainScene, nil
}
if id, err := ids.ParseAssetID(s); err == nil {
return id, nil
}
want := strings.TrimSuffix(filepath.ToSlash(s), ".aescene")
var found ids.AssetID
p.Data.Index.Each(func(e *asset.Entry) {
if found.IsZero() && e.Kind == asset.ArtScene && (e.Name == want || strings.TrimSuffix(e.Source, ".aescene") == want) {
found = e.ID
}
})
if found.IsZero() {
return ids.AssetID{}, errs.New(errs.AssetNotFound, "the game data has no scene "+s)
}
return found, nil
}
func (p *Player) sceneName() string {
if e, ok := p.Data.Index.Lookup(p.sceneID); ok && e.Source != "" {
return e.Source
}
return p.sceneID.String()
}
func (p *Player) loadScene() (*scene.Scene, error) {
src, ok := p.Loader.Scene(p.sceneID)
if !ok {
return nil, errs.New(errs.AssetNotFound, "the scene is not in the game data", log.F("asset", p.sceneID.String()))
}
s, report, err := sceneio.Load(src, p.sceneName(), p.Types, p.Loader, sceneio.LoadOptions{KeepIDs: true})
if err != nil {
return nil, err
}
for _, w := range report.Warnings {
p.log.Warn("scene warning", log.F("path", w.Path), log.F("line", w.Pos.Line), log.F("msg", w.Msg))
}
for _, u := range report.Unknown {
p.log.Warn("unknown component preserved", log.F("type", u))
}
return s, nil
}
func (p *Player) replay() error {
if p.cfg.Replay == "" {
return nil
}
data, err := os.ReadFile(p.cfg.Replay)
if err != nil {
return errs.Wrap(err, errs.VFSIO, "cannot read the input recording").With(log.F("path", p.cfg.Replay))
}
svc, ok := engine.ServiceOf[*inputmod.Service](p.Engine.MainWorld(), inputService)
if !ok {
return errs.New(errs.EngineModule, "the input module is not attached, the recording cannot be replayed")
}
meta, err := svc.Replay(data)
if err != nil {
return err
}
p.log.Info("replaying input", log.F("path", p.cfg.Replay), log.F("project", meta.Project), log.F("seed", meta.Seed))
return nil
}
func (p *Player) SceneID() ids.AssetID {
return p.sceneID
}
func (p *Player) LoadScene(id ids.AssetID) error {
if id.IsZero() {
return errs.New(errs.AssetNotFound, "the save names no scene")
}
if _, ok := p.Loader.Scene(id); !ok {
return errs.New(errs.AssetNotFound, "the saved scene is not in this build", log.F("asset", id.String()))
}
prev := p.sceneID
p.sceneID = id
s, err := p.loadScene()
if err != nil {
p.sceneID = prev
return err
}
p.Scene = s
p.bindScene()
if err := p.Scripts.Instantiate(s); err != nil {
p.log.Error("scripts could not start on the loaded scene", log.F("err", err.Error()))
}
return p.attach()
}
func (p *Player) Run() error {
defer p.Close()
defer func() {
if rec := recover(); rec != nil {
p.crash("panic", "run", rec, debug.Stack(), "")
panic(rec)
}
}()
defer p.watchInterrupt()()
d := driver{}
if p.cfg.Frames > 0 {
for i := 0; i < p.cfg.Frames; i++ {
if !p.Engine.Step(d) {
break
}
}
return p.Engine.Err()
}
return p.Engine.Run(d)
}
func (p *Player) writeTrace() {
f, err := os.Create(p.cfg.Trace)
if err != nil {
p.log.Error("cannot write the trace", log.F("path", p.cfg.Trace), log.F("err", err.Error()))
return
}
t := p.Engine.Trace()
if err := trace.WriteChrome(f, t, t.Snapshot(t.Frames())); err != nil {
p.log.Error("cannot write the trace", log.F("path", p.cfg.Trace), log.F("err", err.Error()))
}
f.Close()
}
func (p *Player) Close() {
if p.closed {
return
}
p.closed = true
if p.detach != nil {
p.detach()
p.detach = nil
}
if p.unrec != nil {
p.unrec()
p.unrec = nil
}
if p.Scripts != nil {
p.Scripts.Close()
}
if p.Runner != nil {
p.Runner.Close()
}
if p.cfg.Trace != "" && p.Engine != nil {
p.writeTrace()
}
if p.Modules != nil {
p.Modules.Shutdown()
}
if p.Engine != nil {
p.Engine.Close()
}
if p.Loader != nil {
p.Loader.Close()
}
for i := len(p.closers) - 1; i >= 0; i-- {
p.closers[i]()
}
}