2 Commits
Author SHA1 Message Date
sanspie df8c926338 Web export: one sound button; the site demo starts silent and Try it starts with sound, each remembering its own choice; drop the attack hint the demo does not have
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 4s
ci / checks (push) Failing after 4s
ci / release (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
2026-10-22 13:09:30 +03:00
sanspie 195703820b Stage 10d1: add-component requirements, play diff from history, node labels 2026-10-22 13:09:11 +03:00
48 changed files with 1139 additions and 220 deletions
+3 -2
View File
@@ -308,7 +308,7 @@ Editor code (D129–D130): `editor/core/code` (piece table, line index, document
| ID | Decision |
|---|---|
| D143 | `engine/module.Registry` is the one registration path for engine subsystems and project native code. `sdk.Registry` is a struct over it; user modules keep `Register(*sdk.Registry) error` and are adapted with `sdk.Adapt`. `engine` never imports `engine/module`: `Manifest.Engine()` adapts collected systems and wrapped `engine.Module`s into one `engine.Module`. `Init` runs once per engine after assets mount. `Attach` runs per scene: once for the main scene of `aego run`, once per open scene document in the editor, once per Play world; detach runs in reverse order. Acceptance form: a component is added with one file in the module plus one line in its `Register`, with no edits outside the module. `app.Build` is the single construction path for `aego run` and the editor. |
| D144 | Everything about a component is declared through `ComponentBuilder[T]` and stored in `schema.TypeDescriptor`: display name, category, icon, owner, builtin flag, default, required components, scene uniqueness, creation template, script fields and methods, custom inspector. Types the descriptor cannot name without a cycle are held as `any` and checked by their consumer (`editor/core/templates`, `editor/ext/inspect`, `modules/scripting`), with the owning module in the error. `ScriptFields == nil` exposes every `FlagScript` field. `Template("", setup)` names the template after the type. Component script methods are typed adapters (`Method0..2`, `Proc0..2`) called on the component reference (D196). `ScriptMethod(any)` is a development-only reflection fallback: it logs a warning and fails `Build` under `aego_runtime`. Ref fields whose type is a single ID at offset 0 are read and written by offset; other layouts go through `Get/Set`. At most 255 script fields per component. |
| D144 | Everything about a component is declared through `ComponentBuilder[T]` and stored in `schema.TypeDescriptor`: display name, category, icon, owner, builtin flag, default, required components, scene uniqueness, creation template, script fields and methods, custom inspector. Types the descriptor cannot name without a cycle are held as `any` and checked by their consumer (`editor/core/templates`, `editor/ext/inspect`, `modules/scripting`), with the owning module in the error. `ScriptFields == nil` exposes every `FlagScript` field. `Template("", setup)` names the template after the type. Component script methods are typed adapters (`Method0..2`, `Proc0..2`) called on the component reference (D196). `ScriptMethod(any)` is a development-only reflection fallback: it logs a warning and fails `Build` under `aego_runtime`. Ref fields whose type is a single ID at offset 0 are read and written by offset; other layouts go through `Get/Set`. At most 255 script fields per component. Amended in 10d1: `Requires` adds the missing components, dependencies first, wherever a component is added: the Inspector, `scene add-comp` (by default; `--no-required` keeps the strict behaviour, where missing requirements stay problems, and each added one is reported as a note), Paste and presets, each in the same History step. `mutate.Required` is the one resolver; it refuses a requirement no module registers, one that does not fit the node's space and a one-per-scene requirement the scene already has, and the Inspector shows that reason on the disabled entry. |
| D145 | A subsystem is a package tree with `Module()`: `render2d`, `render3d`, `script/engine` now; `physics`, `audio`, `anim`, `particles` follow the same shape. `scene`, `scene/frame`, `scene/scriptcomp`, `schema`, `render`, `engine` are core. Subsystems never import each other; shared render buckets live in `scene/frame`. Dev-only registration (importers) is a dev hook compiled under `!aego_runtime`; editor parts of built-in modules live in `<module>/editor` and are attached by `editor/builtins`, project editor code uses `sdk.EditorHook` under `aego_editor`. Built-in modules hide the Inspector badge. Package paths and the `editor/builtins` attachment are superseded by D201 and D205. |
| D146 | `engine/builtin.Modules()` lists built-in modules in dependency order; `Build` resolves `Info.Requires` topologically (ties keep `Add` order) and fails on missing or cyclic requirements, system ids without the module prefix, reserved phases for non-builtin modules, unknown icons, unknown required components, duplicate template ids and bad template setups. Create-menu order is module order, then registration order. Module icons are PNG files `<id>@<size>x<scale>.png`; built-in SVG sources are listed by `builtin.IconSources()` for `tools/genicons`. `module.Audit` runs the same registration and icon checks without failing on icon problems and lists every difference (missing, unused, badly named, empty source); `aego icons` prints it and exits 1 on any entry, building a helper under `aego_nonative` for native projects. `engine/builtin` is superseded by the `app/assembly` catalog (D205). |
@@ -486,7 +486,7 @@ The Stage 8 specification numbered these decisions D153, D155, D158, D159, D168
| D280 | Previews: `editor/core/preview` renders scenes and prefabs offscreen on the editor GPU context in two render-lane steps of the task queue (draw, then read back on the next frame), decodes textures on a worker and keeps script heads and data type names for schematic tiles. The view is the scene's own active camera, else a fit-to-content 2D or 3D view; prefabs get a transparent background. The cache is `.aego/cache/thumbs/<key>.png`, the key being the artifact hash plus the hashes of the dependencies of scenes and prefabs; the old `thumbnails/` cache is removed. Without a GPU scenes fall back to a schematic tile. The startup scene's preview is copied to `.aego/cache/preview.png` for the launcher. `editor/core/tasks` is the queue underneath: worker jobs, main and render steps within a frame budget, priorities and cancellation; Task Center (D285) builds on it. Template and example previews are `preview.png` files rendered by `tools/genpreviews` through `make res` under xvfb + llvmpipe; the tool refuses other rasterizers, writes a file only when its bytes change, and the files are never copied into new projects. |
| D281 | Icon manifest: icon uses come from module registrations (components, templates, recorded by `module.AuditUses`) and from `editor/core/iconuse.Editor`, which a test keeps complete by parsing the editor, ui and module-editor sources for icon arguments. `aego icons list [--json] [--out]` prints the manifest; `docs/icons.md` is generated from it and a test keeps it current. `aego icons check` fails on used-but-missing and provided-but-unused icons; engine icons count as unused only in the full check with the editor list, so the native project helper keeps its module-only scope. Tabs show the panel icon (`workspace.Iconed` or `Desc.Icon`), the name without extension, the parent folder when two open files share a name, and a dot for unsaved changes (`workspace.Dirtier`) instead of a title suffix. |
| D282 | Multi-object editing, component sets and presets. The Inspector shows the components every selected node has. Fields that differ show "—"; their tooltip lists the distinct values with counts (first 64 nodes, 6 values). Every edit goes to all selected nodes in one History transaction. Discrete edits (a click, a typed value, a menu item, a paste, a preset) on more than 10 nodes ask first and name the nodes; drags and gizmo moves never ask. Add, Remove and Reset Component act on all selected nodes. Add skips nodes that already have the component. Clipboard: one component (values or the whole component) and a set of components (`application/x-aego-components`). Pasting onto a node without the component adds it; onto a node with it, overwrites the editable fields. Presets are `.aepreset` assets in `assets/presets/`, kind `preset`, importer `preset`. The file is a one-node prefab fragment holding exactly one component or one script attachment, so asset references stay dependencies. "Save as Preset…" and "Apply Preset" sit in the component and script menus, filtered by type. Applying is one History step on all selected nodes; a script preset attaches the script where it is missing. Presets of scripts that do not compile cannot be applied. Reset per field: "Reset to Default" (schema default, then inspector defaults, then zero), "Reset to Prefab Value" on prefab overrides, "Reset to Base Value" on data assets with a base. Lock Inspector keeps the locked nodes and their document across selection and document switches. The lock clears itself when the document closes or all nodes are gone. Favorites (`.aego/user/favorites.aetext`) hold assets (by ID), nodes (scene path + NodeID) and commands. The Favorites panel, the asset menu, the Hierarchy menu and Ctrl+D in the command palette add them. Favorites lead the "All" tab of the palette. Selection history: every explicit `Set/Add/Remove/Toggle` is a step, at most 64. `Prune` and `DropDoc` (undo, Stop, closing) are not steps; empty selections are not steps. `Alt+←/→` walks it, activating the step's document and skipping steps whose document is closed. |
| D283 | Play Changes Diff, isolation, viewport capture. On Stop the editor compares the Play scene with its edit scene, for nodes present in both: the transform (2D or 3D, as a whole) and every editable field of every component and script both sides have. Fields that are `ReadOnly`, `Runtime` or hidden are never listed. Nothing is checked by default, except the keys changed through the Inspector or gizmos during Play: `SetProperty`, `SetTransform` and `SetTransform3D` commands found in the Play history. Nodes created or removed during Play are reported and never transferred. "Apply to scene" is one History step on the edit document. The dialog shows when there are changes (default), always, or never; the choice is stored in `editor.aetext` as `play_diff`. Isolation (`Shift+H`) hides every node outside the isolated subtrees in the Scene View only: the frame runner skips them in extraction (`Runner.SetHidden`), picking and box selection skip them (`shape.Set.Hide`). It is per-document view state, never serialized, and does not touch Game View or Play. Viewport capture (`Shift+F12`) reads the Scene View render target back and writes it to `.aego/captures/<scene>-<time>.png`. It holds what the Scene View renders into its target; gizmos and selection outlines are drawn by the UI on top and are not part of the image. |
| D283 | Play Changes Diff, isolation, viewport capture. On Stop the editor compares the Play scene with its edit scene, for nodes present in both: the transform (2D or 3D, as a whole) and every editable field of every component and script both sides have. Fields that are `ReadOnly`, `Runtime` or hidden are never listed. Nothing is checked by default, except the keys changed through the Inspector or gizmos during Play: `SetProperty`, `SetTransform` and `SetTransform3D` commands found in the Play history. Nodes created or removed during Play are reported and never transferred. "Apply to scene" is one History step on the edit document. The dialog shows when there are changes (default), always, or never; the choice is stored in `editor.aetext` as `play_diff`. Isolation (`Shift+H`) hides every node outside the isolated subtrees in the Scene View only: the frame runner skips them in extraction (`Runner.SetHidden`), picking and box selection skip them (`shape.Set.Hide`). It is per-document view state, never serialized, and does not touch Game View or Play. Viewport capture (`Shift+F12`) reads the Scene View render target back and writes it to `.aego/captures/<scene>-<time>.png`. It holds what the Scene View renders into its target; gizmos and selection outlines are drawn by the UI on top and are not part of the image. Amended in 10d1: the source of the diff is the Play history. Every field set (`commands.Mutation`), `SetTransform` and `SetTransform3D` in it gives a row; the last edit of a key wins and its value comes from the command, so a gizmo edit shows the value set even when the game moved the node afterwards. Rows equal to the edit scene are dropped, and with no rows and no nodes created or removed through the history the dialog does not show in any mode. The old comparison of the two scenes stays as "Show all differences" (`play_diff_all` in `editor.aetext`, off by default): its extra rows are unchecked and marked "from gameplay". Rows name the node by `props.NodeLabel` (icon and name, the path as the tooltip). Gizmo drags write through `props.Access.Set`, and `mutate` addresses script fields by the script's full type name, so script fields edited in the Inspector go through the same mutation. |
| D284 | Asset references. The reverse map is built on demand from `asset.Entry.Deps`, which every importer of a document writes (scene, prefab, data, table, preset); `project.aego` adds itself as a referrer of `mainScene`, and dirty open documents add themselves by scanning live values. Places are found lazily: open documents through schema access on the live scene, closed files by walking `aetext` values. Replacing a reference edits open documents in one History transaction each (prefab instances only where the field is overridden) and rewrites closed files by replacing the `asset("<id>")` token, after copying each original to `.aego/recovery/refs-<time>/<path>`; changed files are reimported and the work shows as one Task Center entry listing the files; closed files have no Undo. Safe Delete lists the referrers and offers Show References (replace or clear there, then delete), Delete Anyway or Cancel. The pipeline keeps a graveyard (`.aego/cache/graveyard.aetext`, at most 2000 entries: id, last path, kind, time) written whenever `scan` loses a source, so Safe Delete, outside deletions and index repair all record it; Missing References groups unknown ids with their referrers, suggests an asset with the same file name and kind, and replaces or clears; missing node references inside scenes and prefabs can be selected or cleared. Batch Rename plans names from a pattern (`{name}`, `{n}`, `{n:03}`, `{parent}`, `{ext}`), literal or regex find/replace and case, refuses empty, invalid and colliding names (case-insensitive for assets), renames assets through `assetops.Ops` (two-phase through temporary names when the batch renames into its own names) and nodes in one transaction. References from scripts by string path are not detected. |
| D285 | Background work and project health. `editor/core/tasks.Queue` runs worker jobs and budgeted main and render steps; tasks carry title, kind, detail, progress, state and error, and the last 50 finished ones (successful previews excluded) stay listed. Task Center shows them with Cancel; the status bar shows a spinner and the count of non-preview tasks, or failed ones, and opens Task Center. Project Health runs on demand: import errors, problems by owner, missing references, unused assets (roots: the startup scene, every scene and prefab, every script, `assets/presets` and excluded paths; reached through `Deps`), scenes nothing references, names that differ only in case, then on a worker textures larger than 4096 px and scenes whose artifact holds no camera component (a camera that comes from a prefab is not seen, so this is informational). Opening a scene records its load time and node count; the panel shows them with the scene's asset and missing counts. `editor/core/vcs.Provider` (`Root`, `Show`, `Status`, `Log`) has one git implementation through `os/exec`, inside the project's repository only. Structural Diff compares the working copy with HEAD or one of the last 50 commits of the file: scenes and prefabs as trees matched by NodeID (added, removed, moved, reordered, renamed nodes, node attributes, components, fields, nested script and override blocks), other documents field by field; unsaved changes of an open document are not part of it. |
@@ -562,6 +562,7 @@ The Stage 8 specification numbered these decisions D153, D155, D158, D159, D168
| D313 | Gameplay components in `modules/gameplay`: Health (hit points, team, game-time invulnerability that starts after the step so hits of one step add up, `Died` once until `Revive`), Hurtbox2D and Hitbox2D (node-space areas tested exactly as boxes or circles, no physics colliders; policies onEnter, oncePerActivation, periodic; team and friendly fire; knockback is a velocity for a Motion 2D target and an impulse for a dynamic body and turns with the hitbox), Lifetime (game or unscaled seconds, then despawn into the pool or destroy), KillZone2D, Checkpoint2D (priority, once, spawn point node), Respawn (first point is the start position, delay, health and velocity reset) and Pickup2D (`Collected`, once removes the pickup). Zones react to a node's position entering the area. Signals of Health are queued and emitted by the module's systems: `gameplay.post` (PostPhysics: Motion2D events, hits, zones, respawns, health) and `gameplay.frame` (PostUpdate: health changed by scripts, lifetimes). Create → Gameplay has Platformer Character, Top-down Character, Enemy, Moving Platform, Checkpoint, Kill Zone and Pickup. |
| D330 | Health, hitbox, zone, respawn and lifetime state lives in unexported component fields like Motion2D's (D312), so rewind keys, pools and the module's state hash carry it. GMP001 is a validator that blocks Play when the body of a Motion 2D is not kinematic; adding Motion 2D makes an existing body kinematic. |
| D332 | StateMachine and script calls. `contracts/scriptcall.Caller` (view `script.call`, implemented by the scripting module over `script/runtime`) finds a plain method of a script on a node by name and parameter count — never a lifecycle method, an event handler or a coroutine — and calls it; a reference is tied to the instance and the program generation, so a hot reload makes it stale and the owner resolves it again by name. `gameplay.StateMachine` lists state names; hooks `Enter<State>()`, `Exit<State>()`, `Fixed<State>(dt)` and `Update<State>(dt)` are looked up on the node's scripts. `Go(state)` only queues: `gameplay.fsm_fixed` (PrePhysics, after `script.fixed`, before `gameplay.motion`) and `gameplay.fsm_update` (Update, after `script.update`) apply the queued state after each hook, run Exit of the old and Enter of the new state and emit `Changed(from, to)` with state indices; a Go inside Enter or Exit runs after that transition, at most eight transitions per call. The machine enters Initial in the first `fsm_update`, after the scripts' Ready. Time counts fixed steps. GMP005 warns about empty, duplicate or unknown states. Respawn points marked Persist are kept in the save section `gameplay.respawn`; Health.current is a persisted field. The editor plugin edits every gameplay area with eight handles, draws knockback, toggles Gameplay Areas and draws health bars in the Game View. |
| D315 | Examples on the gameplay kit. `examples/platformer` moves its player with Motion2D (input → `Move`, `Jump`, `JumpReleased`), takes damage through `gameplay.Health` and `Hurtbox2D`, stomps enemies with a feet `Hitbox2D` that is active while falling, respawns through `Respawn`, `Checkpoint2D` and a `KillZone2D` pit, tracks Idle, Run and Air in a `StateMachine` and shakes the camera on `Landed`; enemies take contact damage and knockback from a periodic `Hitbox2D` and die through `Health`. The native module keeps only the data type `EnemyStats`. `examples/topdown` is a twin-stick room with `Motion2D` in top-down mode, a sword `Hitbox2D` pulsed on attack, grunts patrolling and chasing through a `StateMachine`, tagged pickups, a checkpoint and a lava kill zone (`aego new --example topdown`). A recorded run (`tests/integration/testdata/platformer_run.aeinput`) must reach the end of the level with two kills and no respawn and replay with the same state hash; it is re-recorded only with `-update`. |
## 4. Dependency rules
+1 -1
View File
@@ -22,7 +22,7 @@ WEB_SMOKE = cd tools/websmoke && npm ci --no-audit --no-fund && CHROMIUM=$(CHROM
WASM_BUDGET = 8388608
WEB_DEMO_PROJECT ?= examples/platformer
WEB_DEMO = cd tools/websmoke && CHROMIUM=$(CHROMIUM) node demo.mjs --site=$(CURDIR)/site
WEB_DEMO_QUIET = sed -i 's/<html lang="en"/<html lang="en" data-music="off"/' site/demo/index.html
WEB_DEMO_QUIET = sed -i 's/<html lang="en"/<html lang="en" data-sound="off"/' site/demo/index.html
WEB_DEMO_BUDGET = @size=$$(stat -c %s site/demo/game.wasm.gz); echo "demo game.wasm.gz: $$size bytes"; \
if [ $$size -gt $(WASM_BUDGET) ]; then echo "the web demo is over the 8 MB gzip budget"; exit 1; fi
TRY_BUDGET = 10485760
+45 -3
View File
@@ -65,9 +65,8 @@ func sceneCommands() []*Command {
{Name: "scene.rename", Doc: "rename a node", Write: true, Args: []Arg{argFile, argNode, {Name: "name", Type: "string", Doc: "new name", Required: true, Pos: true}}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Rename", mutate.Rename(a.Str("node"), a.Str("name")))
}},
{Name: "scene.add-comp", Doc: "add a component with its defaults", Write: true, Args: []Arg{argFile, argNode, {Name: "comp", Type: "string", Doc: "component name, full or a unique suffix (Body2D)", Required: true, Pos: true}}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Add Component", mutate.AddComp(a.Str("node"), a.Str("comp")))
}},
{Name: "scene.add-comp", Doc: "add a component with its defaults and the components it requires", Write: true, Args: []Arg{argFile, argNode, {Name: "comp", Type: "string", Doc: "component name, full or a unique suffix (Body2D)", Required: true, Pos: true},
{Name: "no-required", Type: "boolean", Doc: "add only this component; missing requirements stay as problems"}}, Run: (*Session).sceneAddComp},
{Name: "scene.remove-comp", Doc: "remove a component", Write: true, Args: []Arg{argFile, argNode, {Name: "comp", Type: "string", Doc: "component name", Required: true, Pos: true}}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Remove Component", mutate.RemoveComp(a.Str("node"), a.Str("comp")))
}},
@@ -93,6 +92,49 @@ func (s *Session) scene(a Args) (string, []byte, *mutate.Document, error) {
return rel, src, doc, err
}
// sceneAddComp adds a component and, unless no-required is set, the components it requires first, in one transaction (D144).
func (s *Session) sceneAddComp(a Args) (*Output, error) {
rel, src, doc, err := s.scene(a)
if err != nil {
return nil, err
}
node, comp := a.Str("node"), a.Str("comp")
ops := []mutate.Op{mutate.AddComp(node, comp)}
var notes []string
if !a.Bool("no-required") {
td, err := doc.Component(comp)
if err != nil {
return nil, err
}
h, err := doc.Node(node)
if err != nil {
return nil, err
}
req, err := mutate.Required(doc.Scene, td, h)
if err != nil {
return nil, err
}
ops = ops[:0]
for _, r := range req {
ops = append(ops, mutate.AddComp(node, r.FullName))
notes = append(notes, "added "+label(r)+" (required by "+label(td)+")")
}
ops = append(ops, mutate.AddComp(node, td.FullName))
}
out, err := s.applyDoc(a, rel, src, doc, mutate.Tx{Label: "Add Component", Ops: ops})
if out != nil {
out.Notes = append(notes, out.Notes...)
}
return out, err
}
func label(td *schema.TypeDescriptor) string {
if td.DisplayName != "" {
return td.DisplayName
}
return td.FullName
}
func (s *Session) sceneTx(a Args, label string, ops ...mutate.Op) (*Output, error) {
rel, src, doc, err := s.scene(a)
if err != nil {
+3 -7
View File
@@ -22,12 +22,9 @@ canvas { display: block; width: 100%; height: 100%; outline: none; touch-action:
#fullscreen:hover { opacity: 1; }
#mute { position: fixed; top: 10px; right: 52px; width: 36px; height: 36px; border: 0; border-radius: 6px; background: rgba(0, 0, 0, 0.45); color: #fff; padding: 0; cursor: pointer; opacity: 0.5; }
#mute:hover { opacity: 1; }
#music { position: fixed; top: 10px; right: 94px; width: 36px; height: 36px; border: 0; border-radius: 6px; background: rgba(0, 0, 0, 0.45); color: #fff; padding: 0; cursor: pointer; opacity: 0.5; }
#music:hover { opacity: 1; }
#fullscreen svg, #mute svg, #music svg { display: block; width: 18px; height: 18px; margin: auto; }
#mute .off, #mute.muted .on, #music .off { display: none; }
#mute.muted .off, #music.muted .off { display: inline; }
#music.muted .note { opacity: 0.55; }
#fullscreen svg, #mute svg { display: block; width: 18px; height: 18px; margin: auto; }
#mute .off, #mute.muted .on { display: none; }
#mute.muted .off { display: inline; }
</style>
<link rel="preload" href="game.wasm?v={{.Build}}" as="fetch" type="application/wasm" crossorigin>
</head>
@@ -40,7 +37,6 @@ canvas { display: block; width: 100%; height: 100%; outline: none; touch-action:
<pre id="error" hidden></pre>
</div>
<button id="fullscreen" type="button" title="Fullscreen" aria-label="Fullscreen" hidden><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M9.92 13.44L10.25 13.38L13.38 13.38L13.38 10.25L13.44 9.92L13.76 9.52L14.08 9.39L14.58 9.44L14.98 9.76L15.12 10.25L15.12 14.25L15.06 14.58L14.74 14.98L14.25 15.12L10.25 15.12L9.92 15.06L9.52 14.74L9.38 14.25L9.52 13.76ZM13.44 6.08L13.38 5.75L13.38 2.62L10.25 2.62L9.92 2.56L9.52 2.24L9.39 1.92L9.44 1.42L9.76 1.02L10.25 0.88L14.25 0.88L14.58 0.94L14.98 1.26L15.12 1.75L15.12 5.75L15.06 6.08L14.74 6.48L14.25 6.62L13.76 6.48ZM2.56 9.92L2.62 10.25L2.62 13.38L5.75 13.38L6.08 13.44L6.48 13.76L6.62 14.25L6.56 14.58L6.24 14.98L5.75 15.12L1.75 15.12L1.42 15.06L1.02 14.74L0.88 14.25L0.88 10.25L0.94 9.92L1.26 9.52L1.75 9.38L2.24 9.52ZM6.08 2.56L5.75 2.62L2.62 2.62L2.62 5.75L2.56 6.08L2.24 6.48L1.75 6.62L1.42 6.56L1.02 6.24L0.88 5.75L0.88 1.75L0.94 1.42L1.26 1.02L1.75 0.88L5.75 0.88L6.08 0.94L6.48 1.26L6.62 1.75L6.48 2.24Z"/></svg></button>
<button id="music" type="button" title="Music off" aria-label="Music off" hidden><svg viewBox="0 0 16 16" aria-hidden="true"><g class="note" fill="currentColor"><path d="M5.6 3.4L13.6 1.4V3.5L5.6 5.5Z"/><path d="M5.6 4H6.9V12.3H5.6ZM12.3 1.8H13.6V10.5H12.3Z"/><ellipse cx="4.3" cy="12.4" rx="2.3" ry="1.7" transform="rotate(-20 4.3 12.4)"/><ellipse cx="11" cy="10.6" rx="2.3" ry="1.7" transform="rotate(-20 11 10.6)"/></g><path class="off" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" d="M2 2L14 14"/></svg></button>
<button id="mute" type="button" title="Mute" aria-label="Mute" hidden><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M1.81 6.04L4 6L6.74 3.47L7.11 3.41L7.36 3.55L7.5 3.9L7.5 12.1L7.36 12.45L7.11 12.59L6.74 12.53L4 10L2 10L1.65 9.85L1.5 9.5L1.5 6.5L1.58 6.22Z"/><path class="on" fill="currentColor" d="M12.53 5.89L11.97 4.96L11.01 3.9L10.95 3.62L10.99 3.33L11.15 3.08L11.39 2.91L11.82 2.86L12.21 3.05L12.72 3.54L13.53 4.59L13.91 5.29L14.17 5.95L14.35 6.63L14.5 7.96L14.46 8.75L14.34 9.45L14.14 10.13L13.58 11.34L13.18 11.92L12.67 12.52L12.09 13.04L11.67 13.15L11.39 13.09L11.15 12.92L10.99 12.67L10.95 12.38L11.09 11.97L11.61 11.46L12.27 10.59L12.73 9.61L12.88 9.08L12.97 8.54L12.97 7.46L12.73 6.39ZM10.05 7.09L9.82 6.68L9.38 6.19L9.26 5.77L9.31 5.48L9.48 5.24L9.72 5.08L10.01 5.02L10.29 5.08L10.54 5.24L11.01 5.76L11.27 6.16L11.66 7.18L11.75 8.04L11.58 9.12L11.23 9.91L10.54 10.76L10.29 10.92L10.01 10.98L9.72 10.92L9.48 10.76L9.31 10.52L9.26 10.23L9.38 9.81L9.95 9.12L10.2 8.47L10.24 7.76Z"/><path class="off" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" d="M10.25 5.75l4.5 4.5M14.75 5.75l-4.5 4.5"/></svg></button>
<script src="wasm_exec.js?v={{.Build}}"></script>
<script src="loader.js?v={{.Build}}"></script>
+1 -17
View File
@@ -2,7 +2,7 @@
(function () {
const build = document.documentElement.dataset.build || "";
if (document.documentElement.dataset.music === "off") globalThis.aegoMusicDefault = false;
if (document.documentElement.dataset.sound === "off") globalThis.aegoSoundDefault = false;
const canvas = document.getElementById("game");
const loader = document.getElementById("loader");
const bar = document.getElementById("bar");
@@ -10,7 +10,6 @@
const errorBox = document.getElementById("error");
const full = document.getElementById("fullscreen");
const mute = document.getElementById("mute");
const music = document.getElementById("music");
function syncMute() {
const on = globalThis.aegoAudioMute();
@@ -18,12 +17,6 @@
mute.title = on ? "Unmute" : "Mute";
mute.setAttribute("aria-label", mute.title);
}
function syncMusic() {
const on = globalThis.aegoGame.music();
music.classList.toggle("muted", !on);
music.title = on ? "Music off" : "Music on";
music.setAttribute("aria-label", music.title);
}
const PARALLEL = 6;
const started = performance.now();
let total = 0;
@@ -54,10 +47,6 @@
mute.hidden = false;
syncMute();
}
if (globalThis.aegoGame && typeof globalThis.aegoGame.music === "function") {
music.hidden = false;
syncMusic();
}
canvas.focus();
};
@@ -71,11 +60,6 @@
syncMute();
canvas.focus();
});
music.addEventListener("click", () => {
globalThis.aegoGame.music(!globalThis.aegoGame.music());
syncMusic();
canvas.focus();
});
document.addEventListener("fullscreenchange", () => {
full.hidden = !!document.fullscreenElement;
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 159 KiB

+6 -4
View File
@@ -39,14 +39,16 @@ const (
)
type Env struct {
Clip context.DataClipboard
Types *scene.Types
Res asset.Resolver
OnAdd func(*schema.TypeDescriptor) func(*scene.Scene, scene.NodeHandle)
Clip context.DataClipboard
Types *scene.Types
Res asset.Resolver
OnAdd func(*schema.TypeDescriptor) func(*scene.Scene, scene.NodeHandle)
Notify func(string)
}
func EnvOf(ctx *context.Editor) Env {
e := Env{Clip: ctx.Clipboard, Types: ctx.Types, OnAdd: ctx.OnAdd}
e.Notify = func(text string) { ctx.Notify(context.LevelWarn, text) }
if ctx.Loader != nil {
e.Res = ctx.Loader
}
+77
View File
@@ -0,0 +1,77 @@
package clipboard
import (
"testing"
"aego/core/ids"
"aego/core/log"
"aego/editor/core/command"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/editor/core/selection"
"aego/engine/module"
"aego/scene"
"aego/scene/sceneio"
"aego/schema"
)
type reqBase struct {
A float32 `aego:"a"`
}
type reqNeeds struct {
B float32 `aego:"b"`
}
type reqModule struct{}
func (reqModule) Info() module.Info { return module.Info{ID: "reqtest"} }
func (reqModule) Register(r *module.Registry) error {
if err := module.Component[reqBase](r, schema.Name("reqtest.Base")).Err(); err != nil {
return err
}
return module.Component[reqNeeds](r, schema.Name("reqtest.Needs")).Requires("reqtest.Base").Err()
}
// Pasting a component adds what it requires in the same undo step (D144).
func TestPasteAddsRequiredComponents(t *testing.T) {
types := scene.NewTypes(schema.NewRegistry())
if err := sceneio.RegisterUnknown(types); err != nil {
t.Fatal(err)
}
if _, err := module.Load(types, nil, reqModule{}); err != nil {
t.Fatal(err)
}
if err := types.Schema().Freeze(); err != nil {
t.Fatal(err)
}
s := scene.New(types, nil, scene.Options{})
src := s.Create(s.Root(), "Src")
dst := s.Create(s.Root(), "Dst")
scene.Add[reqBase](s, src)
scene.Add[reqNeeds](s, src).B = 4
s.Flush()
m := document.NewManager(document.Options{Root: t.TempDir(), Types: types, Log: log.Nop()})
d := m.New("scenes/r.aescene", s)
ctx := &context.Editor{Types: types, Docs: m, Selection: selection.New(), Commands: command.New(), Clipboard: &fakeClip{}}
needs, _ := types.Schema().ByName("reqtest.Needs")
if err := CopyComponent(ctx, s, s.ID(src), needs.ID); err != nil {
t.Fatal(err)
}
if err := PasteComponent(ctx, d, []ids.NodeID{s.ID(dst)}); err != nil {
t.Fatal(err)
}
if !scene.Has[reqBase](s, dst) || !scene.Has[reqNeeds](s, dst) {
t.Fatal("paste did not add the required Base")
}
if n, _ := scene.Get[reqNeeds](s, dst); n.B != 4 {
t.Fatalf("pasted value %v", n.B)
}
if err := d.History.Undo(); err != nil {
t.Fatal(err)
}
if scene.Has[reqBase](s, dst) || scene.Has[reqNeeds](s, dst) {
t.Fatal("one Undo must remove both")
}
}
+13
View File
@@ -10,6 +10,7 @@ import (
"aego/editor/core/history"
"aego/editor/core/history/commands"
"aego/scene"
"aego/scene/mutate"
"aego/scene/sceneio"
"aego/scene/scriptcomp"
"aego/schema"
@@ -32,6 +33,18 @@ func (e Env) pasteInto(tx *history.Transaction, d *document.SceneDocument, nodes
if !s.Accepts(h, td.ID) {
continue
}
req, err := mutate.Required(s, td, h)
if err != nil {
if e.Notify != nil {
e.Notify(s.Name(h) + ": " + err.Error())
}
continue
}
for _, r := range req {
if err := tx.Exec(commands.NewMutation("Add "+r.FullName, env, commands.AddComp(s, n, r, nil))); err != nil {
return err
}
}
if err := tx.Exec(commands.NewMutation("Add "+td.FullName, env, commands.AddComp(s, n, td, values))); err != nil {
return err
}
+3 -26
View File
@@ -1,39 +1,16 @@
package gizmo
import (
"aego/core/errs"
"aego/editor/core/history/commands"
"aego/core/ids"
"aego/editor/core/props"
"aego/schema"
)
func SetProperty(e *EditCtx, t schema.TypeID, fd *schema.FieldDescriptor, v schema.Value) {
if e.Err != nil || e.Tx == nil || fd == nil {
if e.Err != nil || e.Tx == nil || fd == nil || e.Scene.Handle(e.Node).IsZero() {
return
}
h := e.Scene.Handle(e.Node)
if h.IsZero() {
return
}
td, ok := e.Scene.Registry().ByID(t)
if !ok {
e.Err = errs.New(errs.SchemaUnknownType, "component type is not registered")
return
}
nv, err := props.Normalize(fd, v)
if err != nil {
e.Err = err
return
}
before, err := e.Scene.GetProperty(h, t, fd.ID)
if err != nil {
e.Err = err
return
}
if props.Equal(before, nv) {
return
}
if err := e.Tx.Exec(commands.SetField(e.Scene, e.Node, td, fd, nv, props.Label(fd))); err != nil {
if err := props.New(e.Scene, nil).Set(e.Tx, []ids.NodeID{e.Node}, t, fd.ID, v); err != nil {
e.Err = err
}
}
File diff suppressed because it is too large Load Diff
+66 -4
View File
@@ -109,13 +109,75 @@ func TestApplyIsOneStep(t *testing.T) {
}
}
func TestTouchedSeesInspectorEdits(t *testing.T) {
_, play, id, td := pair(t)
func TestFromHistoryTakesValuesFromCommands(t *testing.T) {
edit, play, id, td := pair(t)
fd, _ := td.Field("tint")
green := schema.Color(mathx.Color{G: 1, A: 1})
if err := play.Props.Set(nil, []ids.NodeID{id}, td.ID, fd.ID, green); err != nil {
t.Fatal(err)
}
if err := play.Props.EditTransform(nil, []ids.NodeID{id}, func(tr *scene.Transform2D) { tr.Position.X = 5 }); err != nil {
t.Fatal(err)
}
ps := play.Scene()
h := ps.Handle(id)
tr := ps.Local(h)
tr.Position.X = 9
ps.SetLocal(h, tr)
ps.Create(ps.Root(), "Spawned")
ps.Flush()
r := playdiff.FromHistory(edit, play)
if len(r.Changes) != 2 || len(r.Created) != 0 {
t.Fatalf("changes=%d created=%v, want the tint and the transform only", len(r.Changes), r.Created)
}
for _, c := range r.Changes {
if !c.Checked || c.Gameplay || c.Name != "A" {
t.Fatalf("history row %+v", c)
}
if c.Kind == playdiff.KindTransform2D && c.T2After.Position.X != 5 {
t.Fatalf("transform after = %v, want the edited 5, not the game's 9", c.T2After.Position.X)
}
if c.Kind == playdiff.KindField && c.After.C != green.C {
t.Fatal("tint after is not the value from the command")
}
}
full := playdiff.Full(edit, play, r)
if len(full.Changes) != 2 || len(full.Created) != 1 {
t.Fatalf("full changes=%d created=%v", len(full.Changes), full.Created)
}
}
func TestFullMarksGameChanges(t *testing.T) {
edit, play, id, td := pair(t)
fd, _ := td.Field("tint")
if err := play.Props.Set(nil, []ids.NodeID{id}, td.ID, fd.ID, schema.Color(mathx.Color{G: 1, A: 1})); err != nil {
t.Fatal(err)
}
if !playdiff.Touched(play.History)[playdiff.Key{Node: id, Type: td.ID, Field: fd.ID}] {
t.Fatal("a field set through the Inspector is not touched")
ps := play.Scene()
h := ps.Handle(id)
tr := ps.Local(h)
tr.Position.Y = 7
ps.SetLocal(h, tr)
r := playdiff.FromHistory(edit, play)
full := playdiff.Full(edit, play, r)
if len(r.Changes) != 1 || len(full.Changes) != 2 {
t.Fatalf("history %d, full %d", len(r.Changes), len(full.Changes))
}
g := full.Changes[1]
if g.Kind != playdiff.KindTransform2D || !g.Gameplay || g.Checked {
t.Fatalf("game row %+v, want an unchecked transform from gameplay", g)
}
}
func TestFromHistoryEmpty(t *testing.T) {
edit, play, id, _ := pair(t)
ps := play.Scene()
h := ps.Handle(id)
tr := ps.Local(h)
tr.Position.X = 3
ps.SetLocal(h, tr)
r := playdiff.FromHistory(edit, play)
if !r.Empty() || len(r.Created)+len(r.Removed) != 0 {
t.Fatalf("a run with no edits produced %+v", r)
}
}
+85
View File
@@ -0,0 +1,85 @@
package props
import (
"strings"
"sync"
"aego/scene"
"aego/schema"
)
const nodeFallbackIcon = "cat.node"
var ranks struct {
sync.Mutex
reg *schema.Registry
rank map[schema.TypeID]int
}
func rankOf(reg *schema.Registry) map[schema.TypeID]int {
ranks.Lock()
defer ranks.Unlock()
if ranks.reg != reg {
ranks.reg, ranks.rank = reg, map[schema.TypeID]int{}
for i, td := range reg.Components() {
ranks.rank[td.ID] = i
}
}
return ranks.rank
}
// NodeIconOf picks the icon of the visible component with the highest NodeIcon, the earliest registered on a tie; empty is true when the node has no visible components.
func NodeIconOf(s *scene.Scene, h scene.NodeHandle) (icon string, empty bool) {
reg := s.Registry()
rank := rankOf(reg)
prio, best, any := 0, 0, false
s.ComponentsOf(h, func(t schema.TypeID) {
td, ok := reg.ByID(t)
if !ok || td.Hidden {
return
}
any = true
r := rank[t]
if td.NodeIcon > 0 && td.Icon != "" && (td.NodeIcon > prio || td.NodeIcon == prio && r < best) {
icon, prio, best = td.Icon, td.NodeIcon, r
}
})
if icon == "" && any {
icon = nodeFallbackIcon
}
return icon, !any
}
func NodeIcon(s *scene.Scene, h scene.NodeHandle) string {
if icon, _ := NodeIconOf(s, h); icon != "" {
return icon
}
return nodeFallbackIcon
}
// NodeLabel is how the editor names a node: its icon, its name (with the parent's when disambiguate) and the path as the tooltip.
func NodeLabel(s *scene.Scene, h scene.NodeHandle, disambiguate bool) (icon, text, tip string) {
if h.IsZero() || !s.IsAlive(h) {
return "", "", ""
}
text = s.Name(h)
if p := s.Parent(h); disambiguate && !p.IsZero() && p != s.Root() {
text = s.Name(p) + " › " + text
}
return NodeIcon(s, h), text, strings.TrimPrefix(s.Path(h), "/")
}
// Ambiguous reports the names that more than one node in list shares.
func Ambiguous(s *scene.Scene, list []scene.NodeHandle) map[string]bool {
seen := make(map[string]int, len(list))
for _, h := range list {
seen[s.Name(h)]++
}
out := map[string]bool{}
for n, c := range seen {
if c > 1 {
out[n] = true
}
}
return out
}
+5
View File
@@ -35,6 +35,7 @@ type Editor struct {
Active string
Workspace string
PlayDiff string
PlayDiffAll bool
Tool int
Grid bool
Snap bool
@@ -136,6 +137,7 @@ func Encode(s *Editor) []byte {
aetext.Field{Name: "active", Value: aetext.String(s.Active)},
aetext.Field{Name: "workspace", Value: aetext.String(s.Workspace)},
aetext.Field{Name: "play_diff", Value: aetext.String(s.PlayDiff)},
aetext.Field{Name: "play_diff_all", Value: aetext.Bool(s.PlayDiffAll)},
aetext.Field{Name: "tool", Value: aetext.Int(int64(s.Tool))},
aetext.Field{Name: "grid", Value: aetext.Bool(s.Grid)},
aetext.Field{Name: "snap", Value: aetext.Bool(s.Snap)},
@@ -203,6 +205,9 @@ func Decode(data []byte, name string) (*Editor, error) {
if v, ok := doc.Field("play_diff"); ok && v.Str != "" {
s.PlayDiff = v.Str
}
if v, ok := doc.Field("play_diff_all"); ok {
s.PlayDiffAll = v.Bool
}
if v, ok := doc.Field("tool"); ok {
s.Tool = int(v.AsInt())
}
+9 -1
View File
@@ -8,6 +8,7 @@ import (
"aego/core/ids"
"aego/core/mathx"
"aego/editor/core/context"
"aego/editor/core/props"
"aego/editor/core/services"
"aego/editor/core/state"
"aego/engine"
@@ -237,8 +238,15 @@ func (p *Panel) Draw(f *services.Frame, r mathx.Rect) {
if rc.Kind == recorder.Fault {
col = th.Error
}
x := row.X + 24
if s != nil && !rc.Owner.IsZero() {
if icon, _, tip := props.NodeLabel(s, s.Handle(rc.Owner), false); icon != "" {
widgets.IconAt(ui, mathx.Rect{X: row.X + 4, Y: row.Y + (row.H-16)/2, W: 16, H: 16}, icon, th.TextDim)
widgets.Tooltip(ui, id, tip)
}
}
ui.PushClip(row)
ui.Text(rc.Line(name), mathx.Vec2{X: row.X + 4, Y: ui.TextY(row)}, col)
ui.Text(rc.Line(name), mathx.Vec2{X: x, Y: ui.TextY(row)}, col)
ui.PopClip()
})
}
+13 -32
View File
@@ -15,12 +15,12 @@ import (
"aego/editor/core/document"
"aego/editor/core/history/commands"
"aego/editor/core/prefabs"
"aego/editor/core/props"
"aego/editor/core/services"
"aego/editor/core/templates"
"aego/editor/core/workspace"
"aego/platform/input"
"aego/scene"
"aego/schema"
"aego/ui/core"
"aego/ui/dnd"
"aego/ui/widgets"
@@ -53,6 +53,7 @@ type Deps struct {
}
type Panel struct {
ambig map[string]bool
d Deps
rows []row
scroll float32
@@ -68,8 +69,6 @@ type Panel struct {
menuID core.ID
sceneMenuID core.ID
doc document.ID
rank map[schema.TypeID]int
rankReg *schema.Registry
}
func New(d Deps) *Panel {
@@ -280,6 +279,11 @@ func (p *Panel) build(d *document.SceneDocument) {
p.rows = append(p.rows, row{h: h, id: s.ID(h)})
}
}
hs := make([]scene.NodeHandle, len(p.rows))
for i := range p.rows {
hs[i] = p.rows[i].h
}
p.ambig = props.Ambiguous(s, hs)
return
}
p.walk(s, d.View.Expanded, s.Root(), 0, 0)
@@ -323,32 +327,6 @@ func rowID(id ids.NodeID) core.ID {
return core.IDSeq(core.ID(id.Hi), int(uint32(id.Lo)))
}
func (p *Panel) nodeIcon(s *scene.Scene, h scene.NodeHandle) (string, bool) {
reg := s.Registry()
if p.rankReg != reg {
p.rankReg, p.rank = reg, map[schema.TypeID]int{}
for i, td := range reg.Components() {
p.rank[td.ID] = i
}
}
icon, prio, rank, any := "", 0, 0, false
s.ComponentsOf(h, func(t schema.TypeID) {
td, ok := reg.ByID(t)
if !ok || td.Hidden {
return
}
any = true
r := p.rank[t]
if td.NodeIcon > 0 && td.Icon != "" && (td.NodeIcon > prio || td.NodeIcon == prio && r < rank) {
icon, prio, rank = td.Icon, td.NodeIcon, r
}
})
if icon == "" && any {
icon = "cat.node"
}
return icon, !any
}
func (p *Panel) drawRow(f *services.Frame, d *document.SceneDocument, i int, rr mathx.Rect) {
ui := f.UI
s := d.Scene()
@@ -358,11 +336,11 @@ func (p *Panel) drawRow(f *services.Frame, d *document.SceneDocument, i int, rr
p.drawRename(f, d, rw, rr, selected)
return
}
label := s.Name(rw.h)
label, tip := s.Name(rw.h), ""
if p.filter != "" {
label = s.Path(rw.h)
_, label, tip = props.NodeLabel(s, rw.h, p.ambig[label])
}
icon, empty := p.nodeIcon(s, rw.h)
icon, empty := props.NodeIconOf(s, rw.h)
id := rowID(rw.id)
ev := widgets.TreeRowRect(ui, id, rr, widgets.TreeRow{
Depth: rw.depth,
@@ -378,6 +356,9 @@ func (p *Panel) drawRow(f *services.Frame, d *document.SceneDocument, i int, rr
Last: rw.last,
Lines: rw.lines,
})
if tip != "" {
widgets.Tooltip(ui, id, tip)
}
p.decorate(f, d, rw, rr)
if ev.Toggled {
d.View.Expanded[rw.id] = !d.View.Expanded[rw.id]
+43 -2
View File
@@ -1,6 +1,7 @@
package inspector
import (
"strings"
"testing"
"aego/core/ids"
@@ -13,6 +14,7 @@ import (
"aego/editor/ext/inspect"
"aego/engine/module"
"aego/scene"
"aego/scene/mutate"
"aego/scene/sceneio"
"aego/schema"
)
@@ -29,6 +31,14 @@ type addTop struct {
C float32 `aego:"c"`
}
type addSolo struct {
S float32 `aego:"s"`
}
type addNeedsSolo struct {
N float32 `aego:"n"`
}
type addModule struct{}
func (addModule) Info() module.Info {
@@ -42,6 +52,12 @@ func (addModule) Register(r *module.Registry) error {
if err := module.Component[addMid](r, schema.Name("addtest.Mid")).Requires("addtest.Base").Err(); err != nil {
return err
}
if err := module.Component[addSolo](r, schema.Name("addtest.Solo")).SceneUnique().Err(); err != nil {
return err
}
if err := module.Component[addNeedsSolo](r, schema.Name("addtest.NeedsSolo")).Requires("addtest.Solo").Err(); err != nil {
return err
}
return module.Component[addTop](r, schema.Name("addtest.Top")).Requires("addtest.Mid").Err()
}
@@ -77,11 +93,11 @@ func TestAddBringsRequiredComponents(t *testing.T) {
reg.SetEditor(ctx)
p := New(reg, Deps{Docs: ctx, Selection: ctx, Commands: ctx.Commands, Notices: ctx, Nav: ctx, Problems: ctx, Clip: clipboard.EnvOf(ctx), Collapsed: ctx.Collapsed})
top, _ := types.Schema().ByName("addtest.Top")
if why := p.blocked(s, types.Schema(), top); why != "" {
if why := p.blocked(s, top); why != "" {
t.Fatalf("Top is blocked: %s", why)
}
p.nodes = []ids.NodeID{s.ID(a), s.ID(b)}
if got := p.requiredChain(s, types.Schema(), top, s.ID(a), nil); len(got) != 2 || got[0].FullName != "addtest.Base" || got[1].FullName != "addtest.Mid" {
if got, _ := mutate.Required(s, top, a); len(got) != 2 || got[0].FullName != "addtest.Base" || got[1].FullName != "addtest.Mid" {
t.Fatalf("chain %v", got)
}
p.add(d, top)
@@ -107,3 +123,28 @@ func TestAddBringsRequiredComponents(t *testing.T) {
t.Fatal("Undo removed a component the node had before")
}
}
// A requirement that is one per scene and already in the scene blocks the entry with the reason.
func TestAddBlockedBySceneUniqueRequirement(t *testing.T) {
types := scene.NewTypes(schema.NewRegistry())
if err := sceneio.RegisterUnknown(types); err != nil {
t.Fatal(err)
}
if _, err := module.Load(types, nil, addModule{}); err != nil {
t.Fatal(err)
}
if err := types.Schema().Freeze(); err != nil {
t.Fatal(err)
}
s := scene.New(types, nil, scene.Options{})
scene.Add[addSolo](s, s.Create(s.Root(), "Holder"))
n := s.Create(s.Root(), "N")
s.Flush()
p := New(inspect.NewRegistry(), Deps{})
p.nodes = []ids.NodeID{s.ID(n)}
td, _ := types.Schema().ByName("addtest.NeedsSolo")
why := p.blocked(s, td)
if !strings.Contains(why, "already has one") {
t.Fatalf("blocked = %q, want the scene-unique reason", why)
}
}
+15 -27
View File
@@ -3,7 +3,6 @@ package inspector
import (
"aego/editor/ext/inspect"
"fmt"
"slices"
"strconv"
"strings"
@@ -25,6 +24,7 @@ import (
"aego/engine/module"
"aego/platform/input"
"aego/scene"
"aego/scene/mutate"
"aego/scene/sceneio"
"aego/scene/scriptcomp"
"aego/schema"
@@ -686,32 +686,16 @@ func (p *Panel) reset(d *document.SceneDocument, td *schema.TypeDescriptor) {
})
}
func (p *Panel) blocked(s *scene.Scene, reg *schema.Registry, td *schema.TypeDescriptor) string {
for _, name := range td.Requires {
if _, ok := reg.ByName(name); !ok {
return "needs " + name
}
}
func (p *Panel) blocked(s *scene.Scene, td *schema.TypeDescriptor) string {
if td.SceneUnique && (len(p.nodes) > 1 || inScene(s, td.ID)) {
return "one per scene"
}
return ""
}
// requiredChain lists the components td needs that node n lacks, dependencies first.
func (p *Panel) requiredChain(s *scene.Scene, reg *schema.Registry, td *schema.TypeDescriptor, n ids.NodeID, out []*schema.TypeDescriptor) []*schema.TypeDescriptor {
for _, name := range td.Requires {
req, ok := reg.ByName(name)
if !ok || slices.Contains(out, req) {
continue
for _, n := range p.nodes {
if _, err := mutate.Required(s, td, s.Handle(n)); err != nil {
return err.Error()
}
if _, has := s.GetByType(s.Handle(n), req.ID); has {
continue
}
out = p.requiredChain(s, reg, req, n, out)
out = append(out, req)
}
return out
return ""
}
func inScene(s *scene.Scene, t schema.TypeID) bool {
@@ -789,11 +773,12 @@ func (p *Panel) addComponent(d *document.SceneDocument) {
}
meta := p.reg.Meta(td)
label := meta.DisplayName
if why := p.blocked(s, reg, td); why != "" {
if why := p.blocked(s, td); why != "" {
widgets.HeaderIcon(ui, meta.Icon, label+" — "+why, true)
continue
}
for _, req := range p.requiredChain(s, reg, td, p.nodes[0], nil) {
req, _ := mutate.Required(s, td, s.Handle(p.nodes[0]))
for _, req := range req {
label += " + " + p.reg.Meta(req).DisplayName
}
if first == nil {
@@ -818,12 +803,15 @@ func (p *Panel) addComponent(d *document.SceneDocument) {
func (p *Panel) add(d *document.SceneDocument, td *schema.TypeDescriptor) {
p.confirm(d, "Add "+p.reg.Meta(td).DisplayName, func() error {
s := d.Scene()
reg := s.Registry()
env := commands.WithOnAdd(commands.EnvFor(s), p.d.Clip.OnAdd)
tx := d.History.Begin("Add " + td.FullName)
for _, n := range p.nodes {
list := append(p.requiredChain(s, reg, td, n, nil), td)
for _, t := range list {
req, err := mutate.Required(s, td, s.Handle(n))
if err != nil {
tx.Abort()
return err
}
for _, t := range append(req, td) {
if _, ok := s.GetByType(s.Handle(n), t.ID); ok {
continue
}
+21 -7
View File
@@ -10,6 +10,7 @@ import (
"aego/core/mathx"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/editor/core/props"
"aego/render/texture"
"aego/scene"
"aego/schema"
@@ -32,6 +33,7 @@ type refInfo struct {
kind refKind
label string
tooltip string
icon string
}
func (k refKind) color(th *core.Theme) mathx.Color {
@@ -67,11 +69,11 @@ func nodeState(s *scene.Scene, id ids.NodeID, need string) refInfo {
if h.IsZero() || !s.IsAlive(h) {
return refInfo{kind: refMissing, label: "Missing", tooltip: "Node " + id.String() + " is not in this scene"}
}
where := s.Path(h)
icon, name, where := props.NodeLabel(s, h, false)
if need != "" && !hasComponent(s, h, need) {
return refInfo{kind: refNoComponent, label: s.Name(h) + " · no " + shortType(need), tooltip: where + " has no " + need}
return refInfo{kind: refNoComponent, label: name + " · no " + shortType(need), tooltip: where + " has no " + need, icon: icon}
}
return refInfo{kind: refAlive, label: s.Name(h), tooltip: where}
return refInfo{kind: refAlive, label: name, tooltip: where, icon: icon}
}
func assetState(pl *pipeline.Pipeline, id ids.AssetID) refInfo {
@@ -153,6 +155,10 @@ func (p *Panel) refBox(id core.ID, r mathx.Rect, info refInfo, mixed bool, thumb
}
} else if !mixed {
widgets.Tooltip(ui, hid, info.tooltip)
if info.icon != "" {
widgets.IconAt(ui, mathx.Rect{X: tx, Y: box.Y + (box.H-16)/2, W: 16, H: 16}, info.icon, th.TextDim)
tx += 20
}
}
text, col := info.label, info.kind.color(th)
if mixed {
@@ -339,9 +345,9 @@ func (p *Panel) nodeField(d *document.SceneDocument, id core.ID, r mathx.Rect, v
}
q := strings.ToLower(p.pick)
root := s.Root()
count := 0
var cands []scene.NodeHandle
for _, h := range s.PreOrder() {
if count >= 200 {
if len(cands) >= 200 {
break
}
if h == root || !s.IsAlive(h) {
@@ -353,9 +359,17 @@ func (p *Panel) nodeField(d *document.SceneDocument, id core.ID, r mathx.Rect, v
if need != "" && !hasComponent(s, h, need) {
continue
}
count++
cands = append(cands, h)
}
ambig := props.Ambiguous(s, cands)
for i, h := range cands {
count := i + 1
nid := s.ID(h)
if widgets.Selectable(ui, core.IDSeq(pid, 10+count), s.Path(h), nid == cur) {
icon, text, tip := props.NodeLabel(s, h, ambig[s.Name(h)])
sid := core.IDSeq(pid, 10+count)
picked := widgets.SelectableIcon(ui, sid, icon, text, nid == cur)
widgets.Tooltip(ui, sid, tip)
if picked {
*v = schema.Node(nid)
e = all
ui.ClosePopup(pid)
+41 -7
View File
@@ -14,8 +14,10 @@ import (
)
type Deps struct {
Docs services.Documents
Notices services.Notices
Docs services.Documents
Notices services.Notices
ShowAll func() bool
SetShowAll func(bool)
}
type Dialog struct {
@@ -24,6 +26,8 @@ type Dialog struct {
open bool
doc document.ID
res pd.Result
hist pd.Result
full pd.Result
scroll float32
}
@@ -35,14 +39,33 @@ func (g *Dialog) Active() bool {
return g.open
}
func (g *Dialog) Open(ui *core.Context, doc document.ID, res pd.Result) {
g.open, g.doc, g.res, g.scroll = true, doc, res, 0
func (g *Dialog) Open(ui *core.Context, doc document.ID, hist, full pd.Result) {
g.open, g.doc, g.hist, g.full, g.scroll = true, doc, hist, full, 0
g.res = hist
if g.d.ShowAll != nil && g.d.ShowAll() {
g.res = full
}
ui.OpenModal(g.id)
}
func (g *Dialog) showAll(ui *core.Context) {
on := g.d.ShowAll != nil && g.d.ShowAll()
if widgets.Checkbox(ui, "Show all differences, including those made by the game", &on) {
if g.d.SetShowAll != nil {
g.d.SetShowAll(on)
}
if on {
g.res = g.full
} else {
g.res = g.hist
}
g.scroll = 0
}
}
func (g *Dialog) close(ui *core.Context) {
g.open = false
g.res = pd.Result{}
g.res, g.hist, g.full = pd.Result{}, pd.Result{}, pd.Result{}
ui.CloseModal()
}
@@ -150,7 +173,8 @@ func (g *Dialog) Draw(ui *core.Context) {
if len(g.res.Changes) > 0 && check(ui, ui.ID("all"), ar, all) {
g.set(0, len(g.res.Changes), all != 1)
}
widgets.LabelDim(ui, "Fields you changed in the Inspector during Play are checked. Everything else changed by the game itself.")
widgets.LabelDim(ui, "Changes you made during Play. Undo in the scene reverts them after Apply.")
g.showAll(ui)
rows := g.rows()
foot := th.RowHeight*2 + th.Padding*2
if len(g.res.Created)+len(g.res.Removed) > 0 {
@@ -202,9 +226,16 @@ func (g *Dialog) drawRow(ui *core.Context, i int, rw row, rr mathx.Rect) {
if check(ui, id, cb, st) {
g.set(rw.first, rw.last, st != 1)
}
c := &g.res.Changes[rw.first]
x := cb.X + cb.W + 4
if c.Icon != "" {
widgets.IconAt(ui, mathx.Rect{X: x, Y: rr.Y + (rr.H-16)/2, W: 16, H: 16}, c.Icon, th.TextDim)
x += 20
}
ui.PushClip(rr)
ui.Text(g.res.Changes[rw.first].Path, mathx.Vec2{X: cb.X + cb.W + 4, Y: ui.TextY(rr)}, th.Text)
ui.Text(c.Name, mathx.Vec2{X: x, Y: ui.TextY(rr)}, th.Text)
ui.PopClip()
widgets.Tooltip(ui, id, c.Tip)
return
}
c := &g.res.Changes[rw.first]
@@ -225,6 +256,9 @@ func (g *Dialog) drawRow(ui *core.Context, i int, rw row, rr mathx.Rect) {
text := c.Text(false) + " → " + c.Text(true)
ui.PushClip(mathx.Rect{X: x, Y: rr.Y, W: max(0, rr.X+rr.W-x), H: rr.H})
ui.Text(text, mathx.Vec2{X: x, Y: ui.TextY(rr)}, th.Text)
if c.Gameplay {
ui.Text(" · from gameplay", mathx.Vec2{X: x + ui.TextSize(text).X, Y: ui.TextY(rr)}, th.TextDisabled)
}
ui.PopClip()
widgets.Tooltip(ui, id, c.Text(false)+"\n→ "+c.Text(true))
}
+34 -3
View File
@@ -8,8 +8,10 @@ import (
"aego/core/mathx"
"aego/editor/core/context"
"aego/editor/core/props"
"aego/editor/core/services"
"aego/editor/core/workspace"
"aego/scene"
"aego/ui/core"
"aego/ui/widgets"
)
@@ -35,6 +37,7 @@ type Panel struct {
scroll float32
selKey string
Opened int
Scene func(path string) *scene.Scene
}
func New(src services.Problems, nav services.Navigation) *Panel {
@@ -65,6 +68,16 @@ func where(pr *context.ScriptProblem) string {
return strconv.Itoa(pr.Line) + ":" + strconv.Itoa(pr.Col)
}
func (p *Panel) node(pr *context.ScriptProblem) (icon, text, tip string) {
if pr.Node.IsZero() || p.Scene == nil {
return "", "", ""
}
if s := p.Scene(pr.Path); s != nil {
return props.NodeLabel(s, s.Handle(pr.Node), false)
}
return "", "", ""
}
func (p *Panel) refresh() (int, int) {
p.list, _ = p.src.ScriptProblems(p.list)
q := strings.ToLower(strings.TrimSpace(p.filter))
@@ -176,9 +189,13 @@ func (p *Panel) Draw(f *services.Frame, r mathx.Rect) {
if sel.Help != "" {
widgets.LabelWrapped(ui, sel.Help, true)
}
info := sel.Path + " " + where(sel)
loc := where(sel)
if _, name, tip := p.node(sel); name != "" {
loc = tip
}
info := sel.Path + " " + loc
if sel.Script() {
info = sel.Path + ":" + where(sel) + " package " + sel.Package
info = sel.Path + ":" + loc + " package " + sel.Package
} else {
info += " " + sel.Category
}
@@ -247,11 +264,25 @@ func (p *Panel) drawRow(ui *core.Context, i int, rr mathx.Rect) {
code = pr.Category + " " + pr.Code
}
loc := where(pr)
nicon, name, tip := p.node(pr)
if name != "" {
loc = name
}
lw := ui.TextSize(loc).X + 12
if nicon != "" {
lw += 20
}
ui.PushClip(mathx.Rect{X: x, Y: rr.Y, W: max(0, rr.X+rr.W-x-lw), H: rr.H})
ui.Text(code, mathx.Vec2{X: x, Y: ui.TextY(rr)}, th.TextDim)
ui.Text(pr.Message, mathx.Vec2{X: x + ui.TextSize(code).X + 10, Y: ui.TextY(rr)}, th.Text)
ui.PopClip()
ui.TextIn(rr, loc, th.TextDim, core.AlignRight)
widgets.Tooltip(ui, id, pr.Help)
if nicon != "" {
widgets.IconAt(ui, mathx.Rect{X: rr.X + rr.W - lw + 6, Y: rr.Y + (rr.H-16)/2, W: 16, H: 16}, nicon, th.TextDim)
}
help := pr.Help
if tip != "" {
help = strings.TrimSpace(tip + "\n" + help)
}
widgets.Tooltip(ui, id, help)
}
+4 -1
View File
@@ -4,6 +4,7 @@ import (
"aego/asset"
"path"
"strconv"
"strings"
"time"
"aego/core/ids"
@@ -165,7 +166,9 @@ func (m *Missing) nodeRow(ui *core.Context, i int, rr mathx.Rect) {
it := m.nodes[i]
id := core.IDSeq(ui.ID("node"), i)
widgets.IconAt(ui, mathx.Rect{X: rr.X + 2, Y: rr.Y + (rr.H-16)/2, W: 16, H: 16}, "cat.node", th.Warn)
text := it.Scene + " · " + it.Path + " · " + it.Owner + "." + it.Field + " → missing node"
node := strings.TrimPrefix(it.Path, "/")
text := it.Scene + " · " + path.Base(node) + " · " + it.Owner + "." + it.Field + " → missing node"
widgets.Tooltip(ui, id, node)
labels := []string{"Select", "Clear"}
ui.PushClip(mathx.Rect{X: rr.X, Y: rr.Y, W: max(0, rr.W-130), H: rr.H})
ui.Text(text, mathx.Vec2{X: rr.X + 22, Y: ui.TextY(rr)}, th.Text)
+14 -1
View File
@@ -269,7 +269,9 @@ func (p *Panel) drawRow(ui *core.Context, i int, rr mathx.Rect) {
ui.TextIn(rr, h.Err.Error(), th.Error, core.AlignRight)
}
} else {
ui.Text(h.Places[rw.place].String(), mathx.Vec2{X: rr.X + 24 + th.Indent, Y: ui.TextY(rr)}, th.TextDim)
text, tip := placeLabel(h.Places[rw.place])
ui.Text(text, mathx.Vec2{X: rr.X + 24 + th.Indent, Y: ui.TextY(rr)}, th.TextDim)
widgets.Tooltip(ui, id, tip)
}
ui.PopClip()
}
@@ -331,3 +333,14 @@ func (p *Panel) footer(ui *core.Context) {
})
}
}
// placeLabel names the node by its last path segment and keeps the path for the tooltip.
func placeLabel(pl refs.Place) (text, tip string) {
p := strings.TrimPrefix(pl.Path, "/")
short := pl
short.Path = path.Base(p)
if p == "" {
short.Path = ""
}
return short.String(), p
}
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"aego/editor/core/command"
"aego/editor/core/context"
"aego/editor/core/fuzzy"
"aego/editor/core/props"
"aego/editor/core/services"
"aego/platform/input"
"aego/ui/core"
@@ -153,7 +154,7 @@ func (o *Overlay) collect() {
}
id := s.ID(h)
o.items = append(o.items, Item{
Label: s.Name(h), Detail: s.Path(h), Kind: "node", Tab: TabNodes, Icon: "cat.node",
Label: s.Name(h), Detail: strings.TrimPrefix(s.Path(h), "/"), Kind: "node", Tab: TabNodes, Icon: props.NodeIcon(s, h),
Fav: favs.Item{Kind: favs.Node, Scene: scenePath, Node: id, Label: s.Name(h)}, HasFav: true,
Run: func() {
o.d.Docs.Documents().SetActive(doc)
+6
View File
@@ -7,6 +7,7 @@ import (
"aego/core/clock"
"aego/core/mathx"
"aego/editor/core/context"
"aego/editor/core/props"
"aego/editor/core/services"
"aego/engine"
"aego/scene"
@@ -286,6 +287,11 @@ func (p *Panel) eval(r *row, w *engine.World) {
return
}
setValue(r, v, lane)
if v.Kind == schema.KindNodeRef {
if _, name, _ := props.NodeLabel(s, s.Handle(v.Node), false); name != "" {
r.text = name
}
}
return
}
var host context.ScriptHost
+10 -1
View File
@@ -169,6 +169,8 @@ type App struct {
taskPanel *taskcenter.Panel
playDiff *playdiffpanel.Dialog
diffMode pd.Mode
diffAll bool
diffFull pd.Result
diffPending bool
diffDoc document.ID
diffRes pd.Result
@@ -526,6 +528,12 @@ func (a *App) Init(e *engine.Engine) error {
a.ws.Notices, a.ws.Tracer = a.ctx, e.Trace()
a.consolePanel = console.New(a.consoleSink)
a.problemsPanel = eproblems.New(a.ctx, a.ctx)
a.problemsPanel.Scene = func(path string) *scene.Scene {
if d := a.ctx.Docs.Find(path); d != nil {
return d.Scene()
}
return nil
}
playWorld := func() *engine.World { return a.play.World() }
a.eventsPanel = events.New(events.Deps{World: playWorld, Selection: a.ctx, Notices: a.ctx, Root: p.Root})
a.watchPanel = watch.New(watch.Deps{World: playWorld, Scripts: func() context.ScriptHost { return a.ctx.Scripts }, Viewport: a.viewport})
@@ -687,7 +695,8 @@ func (a *App) Init(e *engine.Engine) error {
if err := a.registerAssetTools(); err != nil {
return err
}
a.playDiff = playdiffpanel.New(playdiffpanel.Deps{Docs: a.ctx, Notices: a.ctx})
a.playDiff = playdiffpanel.New(playdiffpanel.Deps{Docs: a.ctx, Notices: a.ctx,
ShowAll: func() bool { return a.diffAll }, SetShowAll: func(on bool) { a.diffAll = on }})
if err := a.registerPlayDiff(); err != nil {
return err
}
+2 -2
View File
@@ -139,7 +139,7 @@ func (a *App) applyState(st *state.Editor, scenes map[string][]byte) {
if st.Workspace != "" {
a.ws.Switch(st.Workspace)
}
a.diffMode = pd.ParseMode(st.PlayDiff)
a.diffMode, a.diffAll = pd.ParseMode(st.PlayDiff), st.PlayDiffAll
set := &a.ctx.Settings
set.Tool = uint8(st.Tool)
set.Grid, set.Snap = st.Grid, st.Snap
@@ -196,7 +196,7 @@ func (a *App) collect(all bool) *state.Editor {
set := a.ctx.Settings
st := state.Default()
st.Workspace = a.ws.Current().ID
st.PlayDiff = a.diffMode.String()
st.PlayDiff, st.PlayDiffAll = a.diffMode.String(), a.diffAll
st.Tool = int(set.Tool)
st.Grid, st.Snap = set.Grid, set.Snap
st.SnapMove, st.SnapRotate, st.SnapScale = set.SnapMove, set.SnapRotate, set.SnapScale
+9 -5
View File
@@ -17,11 +17,11 @@ func (a *App) beforeStop() {
if edit == nil {
return
}
res := pd.Compute(edit, play, pd.Touched(play.History))
if res.Empty() && a.diffMode == pd.ModeIfChanges {
hist := pd.FromHistory(edit, play)
if hist.Empty() && len(hist.Created)+len(hist.Removed) == 0 {
return
}
a.diffPending, a.diffDoc, a.diffRes = true, edit.ID, res
a.diffPending, a.diffDoc, a.diffRes, a.diffFull = true, edit.ID, hist, pd.Full(edit, play, hist)
}
func (a *App) showPlayDiff() {
@@ -29,8 +29,8 @@ func (a *App) showPlayDiff() {
return
}
a.diffPending = false
a.playDiff.Open(a.ctx.UI, a.diffDoc, a.diffRes)
a.diffRes = pd.Result{}
a.playDiff.Open(a.ctx.UI, a.diffDoc, a.diffRes, a.diffFull)
a.diffRes, a.diffFull = pd.Result{}, pd.Result{}
}
func (a *App) registerPlayDiff() error {
@@ -47,3 +47,7 @@ func (a *App) registerPlayDiff() error {
}
return nil
}
func (a *App) PlayDiffOpen() bool {
return a.playDiff.Active() || a.diffPending
}
+1 -1
View File
@@ -141,7 +141,7 @@ These commands read and change scenes, prefabs, data assets and references witho
| `aego refs find <asset>` | files and fields that reference an asset |
| `aego refs fix <from> [<to>] [--dry-run]` | point every reference to one asset at another (none clears them); originals go to .aego/recovery |
| `aego refs missing` | references to assets and nodes that do not exist |
| `aego scene add-comp <file> <node> <comp> [--dry-run]` | add a component with its defaults |
| `aego scene add-comp <file> <node> <comp> [--no-required] [--dry-run]` | add a component with its defaults and the components it requires |
| `aego scene add-node <file> <name> [--parent v] [--space v] [--index v] [--dry-run]` | add an empty node |
| `aego scene apply <file> [--tx v] [--tx-file v] [--dry-run]` | apply a transaction (tx version=1 { op … }) in one step |
| `aego scene get <file> <node> [<path>]` | a node with its transform and every saved field, or one field |
+1 -1
View File
@@ -64,7 +64,7 @@ Component menus copy and paste values, and the Node card copies all components a
## Keeping Play Changes
When you stop Play after changing something, the editor lists what differs from the scene. Fields you changed in the Inspector during Play are already checked; everything else (positions, velocities, counters) changed by the game itself and stays unchecked. "Apply to scene" copies the checked values as one Undo step. Window → Play chooses whether the list shows when there are changes, always, or never.
When you stop Play after changing something in the Inspector or with a gizmo, the editor lists those changes, already checked, with the value you set (even if the game moved the node afterwards). Stopping without edits shows nothing. **Show all differences** adds everything else the game changed (positions, velocities, counters), unchecked and marked "from gameplay"; the editor remembers the choice. "Apply to scene" copies the checked values as one Undo step. Window → Play can turn the list off.
## Assets and Project Health
+22 -1
View File
@@ -8,6 +8,7 @@ import (
"aego/core/mathx"
"aego/engine"
"aego/engine/module"
"aego/engine/problems"
"aego/scene"
"aego/schema"
"aego/script/bind"
@@ -127,6 +128,26 @@ type Hurtbox2D struct {
Health scene.NodeRef `aego:"health"`
}
const CodeNoHealth = "GMP006"
func validateHurt(ctx module.ValidateContext, c *Hurtbox2D) []problems.Diagnostic {
if !c.Health.IsZero() {
return nil
}
s := ctx.Scene
root := s.Root()
for n := ctx.Node; !n.IsZero(); n = s.Parent(n) {
if scene.Has[Health](s, n) {
return nil
}
if n == root {
break
}
}
return []problems.Diagnostic{{Code: CodeNoHealth, Severity: problems.Warning,
Message: "Hurtbox 2D has no Health on its node or above it, hits on it are ignored"}}
}
func DefaultHurtbox() Hurtbox2D {
return Hurtbox2D{Area: mathx.Rect{X: -0.5, Y: -0.5, W: 1, H: 1}, Multiplier: 1, Active: true}
}
@@ -455,7 +476,7 @@ func registerCombat(r *module.Registry, sig *signals) error {
schema.FieldDoc("multiplier", "Damage taken here is multiplied by this, for weak spots and armour."),
schema.FieldDoc("active", "Off ignores hits."),
schema.FieldDoc("health", "Health that takes the damage. Empty uses this node or its nearest parent with Health."),
).Category(Category).Display("Hurtbox 2D").Icon("gameplay.hurtbox").Default(DefaultHurtbox)
).Category(Category).Display("Hurtbox 2D").Icon("gameplay.hurtbox").Default(DefaultHurtbox).Validate(validateHurt)
hit := module.Component[Hitbox2D](r, schema.Name(NameHitbox), space,
schema.Doc("Damages hurtboxes inside its area, once per target per activation, on every entry or periodically."),
+1 -1
View File
@@ -344,7 +344,7 @@ title: Editor tools
- **Area handles.** Select a node with a hitbox, hurtbox, kill zone, checkpoint or pickup to drag the corners and edges of its area; snapping applies, every drag is one undo step. A hitbox also shows its knockback as an arrow.
- **Gameplay → Gameplay Areas** shows every area dimmed in the Scene View; during Play the Game View draws active hurtboxes and hitboxes, the zones and a health bar over every node with Health.
- **Inspector during Play.** The Runtime fields show the controller's velocity, grounded, wall and ceiling contact, the floor and the body under it; Motion 2D adds a contact summary, Health a bar with the hit points, State Machine the current state and how long it has lasted; Lifetime shows the seconds left.
- **Problems** lists a Motion 2D whose body is not kinematic (GMP001, blocks Play), a platformer with Gravity Scale 0 and no Jump Speed (GMP002) and a state machine with empty, duplicate or unknown states (GMP005).
- **Problems** lists a Motion 2D whose body is not kinematic (GMP001, blocks Play), a platformer with Gravity Scale 0 and no Jump Speed (GMP002), a state machine with empty, duplicate or unknown states (GMP005) and a Hurtbox 2D with no Health on its node or above it (GMP006, a warning: hits on it are ignored).
---
id: gameplay/go
+9 -2
View File
@@ -6,6 +6,7 @@ import (
_ "embed"
"encoding/json"
"math"
"strconv"
"syscall/js"
"unsafe"
@@ -17,6 +18,7 @@ const (
audioProcessor = "aego-out"
audioReport = 2048
muteKey = "aego-muted"
soundDefault = "aegoSoundDefault"
)
var currentAudio *Audio
@@ -37,6 +39,7 @@ type AudioStats struct {
type Audio struct {
g js.Value
muteKey string
ctx js.Value
node js.Value
port js.Value
@@ -120,7 +123,11 @@ func OpenAudio(want platform.AudioSpec) (*Audio, error) {
a.hook(doc, "visibilitychange", func(js.Value) { a.visibility(doc.Get("hidden").Truthy()) })
a.hidden = doc.Get("hidden").Truthy()
}
if v, ok := storageGet(g, muteKey); ok {
a.muteKey = muteKey
if d := g.Get(soundDefault); d.Type() == js.TypeBoolean {
a.muteKey, a.muted = muteKey+"-"+strconv.FormatBool(d.Bool()), !d.Bool()
}
if v, ok := storageGet(g, a.muteKey); ok {
a.muted = v == "1"
}
if activated(g) {
@@ -413,7 +420,7 @@ func (a *Audio) SetMuted(on bool) {
if on {
v = "1"
}
storageSet(a.g, muteKey, v)
storageSet(a.g, a.muteKey, v)
}
func (a *Audio) publish() {
-32
View File
@@ -8,9 +8,7 @@ import (
"strconv"
"syscall/js"
"aego/engine/settings"
"aego/platform"
"aego/schema"
"aego/sdk"
)
@@ -19,8 +17,6 @@ const (
hookGlobal = "aegoGame"
failGlobal = "aegoFail"
startedGlobal = "aegoStarted"
musicDefault = "aegoMusicDefault"
musicMuteID = "audio.bus.music.mute"
)
func Main(mods ...sdk.Module) {
@@ -45,7 +41,6 @@ func Main(mods ...sdk.Module) {
fail(err.Error())
return
}
quietMusic(p, g.Get(musicDefault))
expose(p)
if f := g.Get(startedGlobal); f.Type() == js.TypeFunction {
f.Invoke()
@@ -55,25 +50,6 @@ func Main(mods ...sdk.Module) {
}
}
// quietMusic mutes the music bus when the page asks for it and the player has not chosen yet.
func quietMusic(p *Player, def js.Value) {
k := p.Settings.Key(musicMuteID)
if k == 0 || def.Type() != js.TypeBoolean || def.Bool() {
return
}
if _, set := p.Settings.UserValue(k); set {
return
}
setMusic(p, k, false, false)
}
func setMusic(p *Player, k settings.Key, on, save bool) {
tx := p.Settings.Begin()
if tx.Set(k, schema.Bool(!on)) == nil && tx.Apply() == nil && save {
_ = p.Settings.Save()
}
}
func fail(msg string) {
if f := js.Global().Get(failGlobal); f.Type() == js.TypeFunction {
f.Invoke(msg)
@@ -144,13 +120,5 @@ func expose(p *Player) {
}
return js.Null()
}))
if k := p.Settings.Key(musicMuteID); k != 0 {
api.Set("music", js.FuncOf(func(_ js.Value, a []js.Value) any {
if len(a) > 0 && a[0].Type() == js.TypeBoolean {
setMusic(p, k, a[0].Bool(), true)
}
return !p.Settings.Bool(k)
}))
}
js.Global().Set(hookGlobal, api)
}
+4 -1
View File
@@ -72,7 +72,7 @@ func idRef(s *scene.Scene, h scene.NodeHandle) string {
func (d *Document) resolve(name string) []*schema.TypeDescriptor {
reg := d.reg
if td, ok := reg.ByName(name); ok && td.Kind == schema.TypeComponent {
if td, ok := reg.ByName(name); ok && (td.Kind == schema.TypeComponent || td.Kind == schema.TypeScript) {
return []*schema.TypeDescriptor{td}
}
var found []*schema.TypeDescriptor
@@ -95,6 +95,9 @@ func ambiguous(name string, found []*schema.TypeDescriptor) error {
func (d *Document) component(name string) (*schema.TypeDescriptor, error) {
switch found := d.resolve(name); len(found) {
case 1:
if found[0].Kind != schema.TypeComponent {
return nil, errs.New(errs.SchemaUnknownType, "unknown component", log.F("component", name))
}
return found[0], nil
case 0:
return nil, errs.New(errs.SchemaUnknownType, "unknown component", log.F("component", name))
+2 -4
View File
@@ -532,12 +532,10 @@ func (d *Document) set(op Op, raw aetext.Value, reset bool) (step, error) {
return d.setPose(op, t, raw, reset)
}
s := d.Scene
p, ok := s.GetByType(t.h, t.td.ID)
if !ok {
cur, err := s.GetProperty(t.h, t.td.ID, t.fd.ID)
if err != nil {
return step{}, errs.New(errs.SceneNoComponent, "node has no "+t.td.FullName, log.F("node", d.nodeName(t.h)))
}
var cur schema.Value
t.fd.Get(p, &cur)
var nv schema.Value
if reset {
nv = defaultOf(t.td, t.fd)
+64
View File
@@ -0,0 +1,64 @@
package mutate
import (
"fmt"
"slices"
"strings"
"aego/scene"
"aego/schema"
)
func displayName(td *schema.TypeDescriptor) string {
if td.DisplayName != "" {
return td.DisplayName
}
return td.FullName
}
func inScene(s *scene.Scene, t schema.TypeID) bool {
for _, h := range s.PreOrder() {
if _, ok := s.GetByType(h, t); ok && s.IsAlive(h) {
return true
}
}
return false
}
// Required lists the components td needs that node h lacks, dependencies first (D144).
func Required(s *scene.Scene, td *schema.TypeDescriptor, h scene.NodeHandle) ([]*schema.TypeDescriptor, error) {
reg := s.Registry()
var out []*schema.TypeDescriptor
var walk func(t *schema.TypeDescriptor) error
walk = func(t *schema.TypeDescriptor) error {
for _, name := range t.Requires {
req, ok := reg.ByName(name)
if !ok {
return fmt.Errorf("%s needs %s, which no module registers", displayName(t), name)
}
if slices.Contains(out, req) {
continue
}
if _, has := s.GetByType(h, req.ID); has {
continue
}
if !s.Accepts(h, req.ID) {
return fmt.Errorf("%s needs %s, which does not fit a %s node", displayName(t), displayName(req), strings.ToUpper(spaceName(s.Space(h))))
}
if req.SceneUnique && inScene(s, req.ID) {
return fmt.Errorf("%s needs %s, and the scene already has one", displayName(t), displayName(req))
}
if err := walk(req); err != nil {
return err
}
out = append(out, req)
}
return nil
}
return out, walk(td)
}
// Component resolves a component name the way add-comp does: full, or a unique short name.
func (d *Document) Component(name string) (*schema.TypeDescriptor, error) {
return d.component(name)
}
+26
View File
@@ -301,3 +301,29 @@ func mustImporters(t *testing.T, e *env) *pipeline.ImporterRegistry {
t.Helper()
return pipeline.NewImporterRegistry()
}
// add-comp brings the components a component requires unless no-required asks for the old strict behaviour (D144).
func TestAddCompAddsRequired(t *testing.T) {
e := open(t)
out := e.must("scene.add-comp", agent.Args{"file": level, "node": "PatrolA1", "comp": "CameraFollow2D"})
if len(out.Notes) != 1 || !strings.Contains(out.Notes[0], "added Virtual Camera 2D (required by Camera Follow 2D)") {
t.Fatalf("notes %v", out.Notes)
}
text := string(e.read(level))
if !strings.Contains(text, `component "camera.VirtualCamera2D"`) || !strings.Contains(text, `component "camera.CameraFollow2D"`) {
t.Fatal("the scene lacks the follow camera or its virtual camera")
}
strict := e.must("scene.add-comp", agent.Args{"file": level, "node": "PatrolB1", "comp": "CameraFollow2D", "no-required": true})
if len(strict.Notes) != 0 {
t.Fatalf("no-required added %v", strict.Notes)
}
}
func TestAddCompRefusesScript(t *testing.T) {
e := open(t)
for _, strict := range []bool{false, true} {
if _, err := e.run("scene.add-comp", agent.Args{"file": level, "node": "Player", "comp": "mygame.player.PlayerController", "no-required": strict}); err == nil {
t.Fatalf("no-required=%v: add-comp accepted a script type", strict)
}
}
}
+25
View File
@@ -321,3 +321,28 @@ func TestCombatZeroAllocs(t *testing.T) {
t.Fatalf("%v allocations per tick", a)
}
}
func TestHurtboxWithoutHealthWarns(t *testing.T) {
r := newRig(t)
parent := r.node(mathx.Vec2{})
child := r.s.Create(parent, "Box")
*scene.Add[gameplay.Hurtbox2D](r.s, child) = gameplay.DefaultHurtbox()
r.s.Flush()
count := func() int {
n := 0
for _, d := range module.ValidateScene(r.s, nil) {
if d.Code == gameplay.CodeNoHealth {
n++
}
}
return n
}
if n := count(); n != 1 {
t.Fatalf("hurtbox with no Health reported %d GMP006, want 1", n)
}
*scene.Add[gameplay.Health](r.s, parent) = gameplay.DefaultHealth()
r.s.Flush()
if n := count(); n != 0 {
t.Fatalf("Health on the parent still reported %d GMP006", n)
}
}
+8 -4
View File
@@ -59,15 +59,19 @@ func TestEditorPlayDiff(t *testing.T) {
one := []ids.NodeID{id}
play.Props.Set(nil, one, td.ID, speed.ID, schema.Float32(7.5))
play.Props.Set(nil, one, td.ID, tint.ID, schema.Color(mathx.Color{R: 0.9, G: 0.2, B: 0.2, A: 1}))
play.Props.EditTransform(nil, one, func(tr *scene.Transform2D) { tr.Position = mathx.Vec2{X: 3.25, Y: -1} })
ps := play.Scene()
h := ps.Handle(id)
tr := ps.Local(h)
tr.Position = mathx.Vec2{X: 3.25, Y: -1}
ps.SetLocal(h, tr)
ps.CreateWithID(ps.Root(), "Coin", spawned)
ps.Flush()
res := pd.Compute(edit, play, map[pd.Key]bool{{Node: id, Type: td.ID, Field: speed.ID}: true})
hist := pd.FromHistory(edit, play)
full := pd.Full(edit, play, hist)
ctx := &context.Editor{UI: env.ui, Types: types, Docs: m, Selection: selection.New(), Settings: context.DefaultSettings()}
dlg := playdiff.New(playdiff.Deps{Docs: ctx, Notices: ctx})
dlg.Open(env.ui, edit.ID, res)
dlg := playdiff.New(playdiff.Deps{Docs: ctx, Notices: ctx, ShowAll: func() bool { return true }})
dlg.Open(env.ui, edit.ID, hist, full)
img, err := env.draw(func(full mathx.Rect) { dlg.Draw(env.ui) })
checkGolden(t, "editor_playdiff", img, err)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
package integration
import (
"os"
"path/filepath"
"strings"
"testing"
"aego/app/edit"
"aego/core/ids"
"aego/core/log"
"aego/core/mathx"
"aego/project"
"aego/schema"
)
// The Inspector writes script fields through a mutation, and Undo restores them.
func TestInspectorSetsScriptField(t *testing.T) {
s, _ := rewindSession(t)
m := s.App.Context().Docs
d := m.Get(m.PlayDoc().ID)
sc := d.Scene()
td, _ := sc.Registry().ByName(playerType)
fd, _ := td.Field("Bounce")
player := sc.ID(sc.Find(sc.Root(), "Player"))
before, err := d.Props.Get(player, td.ID, fd.ID)
if err != nil {
t.Fatal(err)
}
want := mathx.Vec2{X: 1, Y: -3}
if err := d.Props.Set(nil, []ids.NodeID{player}, td.ID, fd.ID, schema.Vec2(want)); err != nil {
t.Fatal(err)
}
if v, _ := d.Props.Get(player, td.ID, fd.ID); v.V != want {
t.Fatalf("Bounce = %v, want %v", v.V, want)
}
if err := d.History.Undo(); err != nil {
t.Fatal(err)
}
if v, _ := d.Props.Get(player, td.ID, fd.ID); v.V != before.V {
t.Fatalf("Undo left Bounce at %v, want %v", v.V, before.V)
}
}
// A script field set on a prefab instance is saved as an override and survives Undo and Redo.
func TestScriptFieldOverrideSaves(t *testing.T) {
root := copyExample(t, "platformer")
p, err := project.Load(root)
if err != nil {
t.Fatal(err)
}
s, err := edit.Open(p, edit.Config{Headless: true, NoWatch: true, Logger: log.Nop()}, combatModule{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(s.Close)
s.Step()
m := s.App.Context().Docs
d, err := m.Open("scenes/main.aescene")
if err != nil {
t.Fatal(err)
}
sc := d.Scene()
td, ok := sc.Registry().ByName("mygame.enemy.EnemyAI")
if !ok {
t.Fatal("EnemyAI is not registered")
}
fd, _ := td.Field("PointA")
enemy := sc.ID(sc.Find(sc.Root(), "EnemyLeft"))
target := sc.ID(sc.Find(sc.Root(), "Checkpoint"))
if target.IsZero() {
t.Fatal("no Checkpoint node")
}
if err := d.Props.Set(nil, []ids.NodeID{enemy}, td.ID, fd.ID, schema.Node(target)); err != nil {
t.Fatal(err)
}
if err := d.History.Undo(); err != nil {
t.Fatal(err)
}
if err := d.History.Redo(); err != nil {
t.Fatal(err)
}
if v, _ := d.Props.Get(enemy, td.ID, fd.ID); v.Node != target {
t.Fatalf("PointA after Undo and Redo = %v", v.Node)
}
if err := m.Save(d.ID); err != nil {
t.Fatal(err)
}
src, err := os.ReadFile(filepath.Join(root, "scenes", "main.aescene"))
if err != nil {
t.Fatal(err)
}
want := `prop="script/mygame.enemy.EnemyAI.PointA" value=node("` + target.String() + `")`
if !strings.Contains(string(src), want) {
t.Fatalf("saved scene lacks %s", want)
}
}
+8 -4
View File
@@ -27,18 +27,22 @@ func (b *builder) sceneTree(src, root string) string {
label = "1 node"
}
c.WriteString(`<details class="scene-tree"><summary>` + b.icon(root, "file.scene", "") + `<span>Scene</span><span class="count">` + label + `</span>` + b.icon(root, "ui.chevron_right", "chev") + `</summary><div class="st-body">`)
b.treeNodes(&c, nodes, root)
b.treeNodes(&c, nodes, root, "")
c.WriteString("</div></details>\n")
return c.String()
}
func (b *builder) treeNodes(c *strings.Builder, nodes []sceneio.OutlineNode, root string) {
func (b *builder) treeNodes(c *strings.Builder, nodes []sceneio.OutlineNode, root, parent string) {
for _, n := range nodes {
name := n.Name
if name == "" {
name = "Node"
}
c.WriteString(`<div class="st-node"><div class="st-head">` + b.icon(root, "node.empty", "") + esc(name))
p := name
if parent != "" {
p = parent + "/" + name
}
c.WriteString(`<div class="st-node"><div class="st-head" title="` + esc(p) + `">` + b.icon(root, "node.empty", "") + esc(name))
if !n.Enabled {
c.WriteString(` <span class="note">disabled</span>`)
}
@@ -48,7 +52,7 @@ func (b *builder) treeNodes(c *strings.Builder, nodes []sceneio.OutlineNode, roo
}
if len(n.Children) > 0 {
c.WriteString(`<div class="st-children">`)
b.treeNodes(c, n.Children, root)
b.treeNodes(c, n.Children, root, p)
c.WriteString(`</div>`)
}
c.WriteString("</div>")
+1 -1
View File
@@ -957,7 +957,7 @@ func (b *builder) home() string {
style = ` style="background-image:url(` + posterAsset + `)"`
}
c.WriteString(`<div class="hero-demo"><div class="demo" data-src="demo/"` + style + `><button type="button" class="demo-play" aria-label="Play the demo"><span class="disc">` + b.icon("", "play.start", "") + `</span><small>Play the demo · runs in the browser</small></button></div>`)
c.WriteString(`<p class="demo-caption">A small platformer made with Aego and exported for the web. A/D or arrows to move, Space to jump, J to attack. <a href="demo/">Open full screen</a></p></div>`)
c.WriteString(`<p class="demo-caption">A small platformer made with Aego and exported for the web. A/D or arrows to move, Space to jump. <a href="demo/">Open full screen</a></p></div>`)
case b.hero:
c.WriteString(`<img class="hero-shot" src="` + heroAsset + `" alt="The Aego editor">`)
}
+1
View File
@@ -3,6 +3,7 @@
(function () {
const blocks = [...document.querySelectorAll(".try[data-scene]")];
if (!blocks.length) return;
globalThis.aegoSoundDefault = true;
const starters = new Map();
const texts = new Map();
let api = null;