355 lines
9.5 KiB
Go
355 lines
9.5 KiB
Go
package projects
|
|
|
|
import (
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"aego/asset"
|
|
"aego/asset/pipeline"
|
|
"aego/core/errs"
|
|
"aego/core/log"
|
|
"aego/engine/res"
|
|
"aego/project"
|
|
)
|
|
|
|
const (
|
|
TemplateEmpty = "empty"
|
|
Template2D = "2d"
|
|
Template25D = "2.5d"
|
|
Template3D = "3d"
|
|
templatesDir = "templates"
|
|
)
|
|
|
|
type Template struct {
|
|
ID string
|
|
Name string
|
|
Description string
|
|
Icon string
|
|
}
|
|
|
|
var Templates = []Template{
|
|
{ID: TemplateEmpty, Name: "Empty", Description: "One scene with a 2D camera", Icon: "cat.node"},
|
|
{ID: Template2D, Name: "2D", Description: "2D camera and a sprite", Icon: "cat.rendering"},
|
|
{ID: Template25D, Name: "2.5D", Description: "Orthographic 3D camera with 2D sprites", Icon: "cat.camera"},
|
|
{ID: Template3D, Name: "3D", Description: "Perspective camera, a plane and a cube", Icon: "cat.transform"},
|
|
}
|
|
|
|
type Options struct {
|
|
Dir string
|
|
Template string
|
|
Module string
|
|
Example string
|
|
Engine string
|
|
Native bool
|
|
}
|
|
|
|
var skipCopy = map[string]bool{".aego": true, "exports": true, ".git": true}
|
|
|
|
var renameExt = map[string]bool{".aego": true, ".aescene": true, ".aeprefab": true, ".aes": true, ".go": true, ".md": true, ".aetext": true}
|
|
|
|
var replaceAego = regexp.MustCompile(`(?m)^(\s*replace\s+aego(?:\s+\S+)?\s*=>\s*)\S+`)
|
|
|
|
const nativeStarter = `package native
|
|
|
|
import "aego/sdk"
|
|
|
|
type Module struct{}
|
|
|
|
func (Module) Info() sdk.ModuleInfo {
|
|
return sdk.ModuleInfo{ID: "game", Version: "0.1.0"}
|
|
}
|
|
|
|
func (Module) Register(r *sdk.Registry) error {
|
|
return nil
|
|
}
|
|
|
|
func Modules() []sdk.Module {
|
|
return []sdk.Module{Module{}}
|
|
}
|
|
`
|
|
|
|
func Create(opt Options) (*project.Project, error) {
|
|
abs, created, err := prepare(opt.Dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := create(abs, opt)
|
|
if err != nil && created {
|
|
os.RemoveAll(abs)
|
|
}
|
|
return p, err
|
|
}
|
|
|
|
func create(abs string, opt Options) (*project.Project, error) {
|
|
module, err := moduleFor(abs, opt.Module)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if opt.Example != "" {
|
|
return fromExample(abs, module, opt)
|
|
}
|
|
id := opt.Template
|
|
if id == "" {
|
|
id = Template2D
|
|
}
|
|
rep := strings.NewReplacer(
|
|
"{{module}}", module,
|
|
"{{template}}", id,
|
|
"{{white}}", asset.BuiltinWhite.String(),
|
|
"{{cube}}", asset.BuiltinCube.String(),
|
|
"{{plane}}", asset.BuiltinPlane.String(),
|
|
)
|
|
if err := writeTemplate(id, abs, rep); err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := project.Load(abs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if opt.Native {
|
|
if err := writeNative(p, opt.Engine); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return p, finish(p)
|
|
}
|
|
|
|
func prepare(dir string) (string, bool, error) {
|
|
if strings.TrimSpace(dir) == "" {
|
|
return "", false, errs.New(errs.VFSBadPath, "project directory is empty")
|
|
}
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return "", false, errs.Wrap(err, errs.VFSBadPath, "cannot resolve the project directory")
|
|
}
|
|
entries, err := os.ReadDir(abs)
|
|
switch {
|
|
case os.IsNotExist(err):
|
|
if err := os.MkdirAll(abs, 0o755); err != nil {
|
|
return "", false, errs.Wrap(err, errs.VFSIO, "cannot create the project directory")
|
|
}
|
|
return abs, true, nil
|
|
case err != nil:
|
|
return "", false, errs.Wrap(err, errs.VFSIO, "cannot read the project directory")
|
|
case len(entries) > 0:
|
|
return "", false, errs.New(errs.ProjectExists, "directory is not empty", log.F("dir", abs))
|
|
}
|
|
return abs, false, nil
|
|
}
|
|
|
|
func moduleFor(abs, given string) (string, error) {
|
|
m := given
|
|
if m == "" {
|
|
m = project.ModuleName(filepath.Base(abs))
|
|
}
|
|
if !project.ValidModule(m) {
|
|
return "", errs.New(errs.ProjectBadFile, "module must match [a-z0-9_/-]+", log.F("module", m))
|
|
}
|
|
if project.ReservedModule(m) {
|
|
return "", errs.New(errs.ProjectBadFile, "module path aego is reserved for the engine", log.F("module", m))
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func templateIDs() string {
|
|
ids := make([]string, len(Templates))
|
|
for i, t := range Templates {
|
|
ids[i] = t.ID
|
|
}
|
|
return strings.Join(ids, ", ")
|
|
}
|
|
|
|
const PreviewFile = "preview.png"
|
|
|
|
func TemplatePreview(id string) ([]byte, error) {
|
|
return fs.ReadFile(res.FS(), path.Join(templatesDir, id, PreviewFile))
|
|
}
|
|
|
|
func ExamplePreview(engine, name string) ([]byte, error) {
|
|
return os.ReadFile(filepath.Join(engine, "examples", name, PreviewFile))
|
|
}
|
|
|
|
func TemplateFiles(id string) []string {
|
|
root := path.Join(templatesDir, id)
|
|
var out []string
|
|
fs.WalkDir(res.FS(), root, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
if name := d.Name(); name == PreviewFile || strings.HasPrefix(name, ".") {
|
|
return nil
|
|
}
|
|
out = append(out, strings.TrimPrefix(p, root+"/"))
|
|
return nil
|
|
})
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func writeTemplate(id, dst string, rep *strings.Replacer) error {
|
|
src := res.FS()
|
|
root := path.Join(templatesDir, id)
|
|
if _, err := fs.Stat(src, path.Join(root, project.FileName)); err != nil {
|
|
return errs.New(errs.ProjectBadFile, "unknown project template "+id, log.F("templates", templateIDs()))
|
|
}
|
|
err := fs.WalkDir(src, root, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel := strings.TrimPrefix(strings.TrimPrefix(p, root), "/")
|
|
target := filepath.Join(dst, filepath.FromSlash(rel))
|
|
if d.IsDir() {
|
|
return os.MkdirAll(target, 0o755)
|
|
}
|
|
if rel == PreviewFile {
|
|
return nil
|
|
}
|
|
data, err := fs.ReadFile(src, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(target, []byte(rep.Replace(string(data))), 0o644)
|
|
})
|
|
if err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot write the project template")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func finish(p *project.Project) error {
|
|
if _, err := p.EnsureDirs(); err != nil {
|
|
return err
|
|
}
|
|
for _, d := range []string{p.CacheDir(), filepath.Join(p.Root, "exports")} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot create the project layout")
|
|
}
|
|
}
|
|
if err := sceneMetas(p); err != nil {
|
|
return err
|
|
}
|
|
return p.Save()
|
|
}
|
|
|
|
func sceneMetas(p *project.Project) error {
|
|
return filepath.WalkDir(p.ScenesDir(), func(file string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() || filepath.Ext(file) != ".aescene" {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(file + pipeline.MetaSuffix); err == nil {
|
|
return nil
|
|
}
|
|
meta := pipeline.NewMeta("scene", 1, nil)
|
|
if err := os.WriteFile(file+pipeline.MetaSuffix, pipeline.WriteMeta(meta), 0o644); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot write a scene sidecar")
|
|
}
|
|
if p.MainScene.IsZero() && filepath.Base(file) == "main.aescene" {
|
|
p.MainScene = meta.ID
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func writeNative(p *project.Project, engine string) error {
|
|
if engine == "" {
|
|
return errs.New(errs.ProjectBadFile, "native projects need the engine source tree, pass --engine or set AEGO_ROOT")
|
|
}
|
|
if err := os.MkdirAll(p.NativeDir(), 0o755); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot create native/")
|
|
}
|
|
if err := os.WriteFile(filepath.Join(p.NativeDir(), "module.go"), []byte(nativeStarter), 0o644); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot write native/module.go")
|
|
}
|
|
gomod := "module " + p.Module + "\n\ngo 1.27\n\nrequire aego v0.0.0\n\nreplace aego => " + filepath.ToSlash(engine) + "\n"
|
|
if err := os.WriteFile(filepath.Join(p.Root, "go.mod"), []byte(gomod), 0o644); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot write go.mod")
|
|
}
|
|
p.Native = true
|
|
return nil
|
|
}
|
|
|
|
func fromExample(abs, module string, opt Options) (*project.Project, error) {
|
|
if opt.Engine == "" {
|
|
return nil, errs.New(errs.ProjectBadFile, "examples need the engine source tree, pass --engine or set AEGO_ROOT")
|
|
}
|
|
src := filepath.Join(opt.Engine, "examples", opt.Example)
|
|
old, err := project.Load(src)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err, errs.ProjectNotFound, "unknown example "+opt.Example).With(log.F("examples", strings.Join(Examples(opt.Engine), ", ")))
|
|
}
|
|
rename := renamer(old.Module, module)
|
|
engine := []byte(filepath.ToSlash(opt.Engine))
|
|
err = filepath.WalkDir(src, func(file string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(src, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
if rel != "." && skipCopy[d.Name()] {
|
|
return filepath.SkipDir
|
|
}
|
|
return os.MkdirAll(filepath.Join(abs, rel), 0o755)
|
|
}
|
|
if !d.Type().IsRegular() || strings.HasSuffix(d.Name(), "_test.go") || rel == PreviewFile {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if renameExt[filepath.Ext(file)] || d.Name() == "go.mod" {
|
|
data = rename(data)
|
|
}
|
|
if d.Name() == "go.mod" {
|
|
data = replaceAego.ReplaceAllFunc(data, func(m []byte) []byte {
|
|
head := replaceAego.FindSubmatch(m)[1]
|
|
return append(append([]byte{}, head...), engine...)
|
|
})
|
|
}
|
|
return os.WriteFile(filepath.Join(abs, rel), data, 0o644)
|
|
})
|
|
if err != nil {
|
|
return nil, errs.Wrap(err, errs.VFSIO, "cannot copy the example")
|
|
}
|
|
p, err := project.Load(abs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Name = filepath.Base(abs)
|
|
return p, finish(p)
|
|
}
|
|
|
|
func renamer(old, next string) func([]byte) []byte {
|
|
if old == "" || old == next {
|
|
return func(b []byte) []byte { return b }
|
|
}
|
|
re := regexp.MustCompile(`(?m)(^|[^A-Za-z0-9_./-]|"script/)` + regexp.QuoteMeta(old) + `([^A-Za-z0-9_-]|$)`)
|
|
repl := []byte("${1}" + next + "${2}")
|
|
return func(b []byte) []byte { return re.ReplaceAll(b, repl) }
|
|
}
|
|
|
|
func Examples(engine string) []string {
|
|
entries, err := os.ReadDir(filepath.Join(engine, "examples"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(filepath.Join(engine, "examples", e.Name(), project.FileName)); err == nil {
|
|
out = append(out, e.Name())
|
|
}
|
|
}
|
|
return out
|
|
}
|