diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d8a9049b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.php] +indent_size = 4 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..a9a5a667 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +data/ +index.html +package-lock.json + +# Markdown is left alone: Prettier pads table cells to equal width, which turns the +# README's long glossary rows into 1000-character lines. +*.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..dc659eab --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "printWidth": 110, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/README.md b/README.md index b5618ba7..bc154e36 100644 --- a/README.md +++ b/README.md @@ -213,9 +213,10 @@ twice a second) in edit and play mode alike; `__gm.fps()` exposes the same numbe Rules that keep it organised: `core/` imports nothing from other layers; `ui/` never sees assets; position exists only on scene objects; overrides are merged in exactly one place (`core/properties/resolve.ts`); the inspector is generated from `PropertySchema` – a new -property is one schema line; a new node is one file. Comments state contracts and reasons, not -what the code does: a `/** */` on every export whose name does not say it all, `//` only for a -decision or gotcha. +property is one schema line; a new node is one file **plus a row in the table in +`tests/core/node-catalog.test.ts`**, which fails if a node has no case. Comments state contracts +and reasons, not what the code does: a `/** */` on every export whose name does not say it all, +`//` only for a decision or gotcha. ## Development @@ -226,11 +227,21 @@ npm install npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production npm test # unit tests (core) npm run typecheck +npm run lint # ESLint, type-aware; --fix for the fixable half +npm run format # Prettier over src, tests and tools +npm run check # typecheck + lint + format:check + tests, what CI would run npm run build # writes dist/ and regenerates index.html node tools/seed-e2e.mjs && node tools/e2e.mjs # 140 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # the example game, played end to end ``` +**Prettier owns the formatting** (110 columns, single quotes, `.prettierrc.json`); Markdown is +excluded because it pads table cells to equal width. ESLint therefore only ever means "this is a +mistake", never "this looks different": `eslint-config-prettier` switches every stylistic rule +off, and what is left is type-aware – unnecessary conditions, floating promises, duplicate +imports, base-to-string. Where a rule fires on something deliberate, the disable comment states +why (the `Log` node's `console.log`, a guard against untyped test hooks). + Each script writes to its own project (`tools/lib/projects.mjs`): the **fixture** project is rewritten by every e2e run and the **jump-run** project by its seeder. The **playground** project is the author's own and is maintained in the editor – every tool refuses to touch it. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..d07c8ffa --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,62 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +/** + * Formatting is Prettier's job – `eslint-config-prettier` switches every stylistic rule off, + * so a rule here always means "this is a mistake", never "this looks different". + */ +export default tseslint.config( + { ignores: ['dist/**', 'data/**', 'node_modules/**', 'index.html'] }, + + // Type-aware linting for everything the tsconfig covers (src, tests, vite.config.ts). + { + files: ['src/**/*.ts', 'tests/**/*.ts', 'vite.config.ts'], + extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked], + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + globals: globals.browser, + }, + rules: { + // The duplicate `import` statements the last refactoring round had to find by hand. + 'no-duplicate-imports': 'error', + // `tsc` already reports unused locals; keep ESLint quiet about the same thing. + '@typescript-eslint/no-unused-vars': 'off', + // Off by design: the codebase builds objects from closures (`createFieldRow` returns + // `{ setValue }`, test fixtures return `{ sceneOf, object }`). Those have no `this`, + // but the rule cannot tell them from class methods and fired ~50 times on them. + '@typescript-eslint/unbound-method': 'off', + // A `?? []` on something that is never nullish hides that the type says otherwise. + '@typescript-eslint/no-unnecessary-condition': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/consistent-type-imports': ['error', { fixStyle: 'inline-type-imports' }], + '@typescript-eslint/no-import-type-side-effects': 'error', + // Async work that nobody awaits silently swallows its rejection. + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], + eqeqeq: ['error', 'always', { null: 'ignore' }], + 'no-console': ['error', { allow: ['warn', 'error'] }], + 'prefer-const': 'error', + 'object-shorthand': ['error', 'properties'], + }, + }, + + // Node scripts: plain JavaScript, no type information available. + { + files: ['tools/**/*.mjs'], + extends: [js.configs.recommended], + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + globals: globals.node, + }, + rules: { + 'no-duplicate-imports': 'error', + eqeqeq: ['error', 'always', { null: 'ignore' }], + 'prefer-const': 'error', + }, + }, + + prettier, +); diff --git a/package-lock.json b/package-lock.json index 7a2aede8..33cae88a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,42 @@ "pixi.js": "^8.6.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^22.20.1", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.12.0", + "prettier": "^3.9.6", "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0", "vite": "^6.0.0", "vitest": "^3.0.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -459,6 +489,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -466,6 +690,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -863,6 +1111,13 @@ "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -870,6 +1125,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -880,6 +1142,236 @@ "undici-types": "~6.21.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -1010,6 +1502,46 @@ "node": ">=10.0.0" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1020,6 +1552,29 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1030,6 +1585,20 @@ "node": ">=8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1057,6 +1626,21 @@ "node": ">= 16" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1085,6 +1669,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/earcut": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", @@ -1140,6 +1731,180 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1150,6 +1915,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -1166,6 +1941,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1184,6 +1980,52 @@ } } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1208,6 +2050,102 @@ "js-binary-schema-parser": "^2.0.3" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/ismobilejs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", @@ -1227,6 +2165,60 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1244,6 +2236,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1270,12 +2278,89 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-svg-path": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1368,6 +2453,62 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -1414,6 +2555,42 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1528,6 +2705,32 @@ "node": ">=14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1542,6 +2745,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1549,6 +2776,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -1720,6 +2957,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1736,6 +2989,29 @@ "engines": { "node": ">=8" } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index d25de080..38d0e055 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,22 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "npm run typecheck && npm run lint && npm run format:check && npm run test" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^22.20.1", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.12.0", + "prettier": "^3.9.6", "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0", "vite": "^6.0.0", "vitest": "^3.0.0" }, diff --git a/src/core/commands/CommandStack.ts b/src/core/commands/CommandStack.ts index e7cc08b5..a1050175 100644 --- a/src/core/commands/CommandStack.ts +++ b/src/core/commands/CommandStack.ts @@ -6,7 +6,10 @@ export class CommandStack { private readonly undoStack: Command[] = []; private readonly redoStack: Command[] = []; - constructor(private readonly project: Project, private readonly limit = 200) {} + constructor( + private readonly project: Project, + private readonly limit = 200, + ) {} execute(command: Command): void { command.execute(this.project); diff --git a/src/core/commands/CompoundCommand.ts b/src/core/commands/CompoundCommand.ts index 104cd929..82de5dc4 100644 --- a/src/core/commands/CompoundCommand.ts +++ b/src/core/commands/CompoundCommand.ts @@ -7,7 +7,10 @@ import type { Command } from './Command'; * so the project is never left half-changed. */ export class CompoundCommand implements Command { - constructor(readonly label: string, private readonly commands: readonly Command[]) {} + constructor( + readonly label: string, + private readonly commands: readonly Command[], + ) {} execute(project: Project): void { const done: Command[] = []; diff --git a/src/core/commands/SetBagValueCommand.ts b/src/core/commands/SetBagValueCommand.ts index abd321c2..3331b9e3 100644 --- a/src/core/commands/SetBagValueCommand.ts +++ b/src/core/commands/SetBagValueCommand.ts @@ -15,7 +15,10 @@ export abstract class SetBagValueCommand implements Command { readonly label: string; private previous: PropertyValue | undefined; - protected constructor(protected readonly key: string, label: string) { + protected constructor( + protected readonly key: string, + label: string, + ) { this.label = label; } diff --git a/src/core/commands/applyCommands.ts b/src/core/commands/applyCommands.ts index ce46b53f..c0b69f39 100644 --- a/src/core/commands/applyCommands.ts +++ b/src/core/commands/applyCommands.ts @@ -55,9 +55,18 @@ interface SlotMemo { * with the template along – copies the user changed on their own keep their value. Returns * the memos in application order; `restoreSlots` undoes them. */ -function applyToTemplate(project: Project, link: TemplateLink, slots: readonly Slot[], values: readonly T[]): SlotMemo[] { +function applyToTemplate( + project: Project, + link: TemplateLink, + slots: readonly Slot[], + values: readonly T[], +): SlotMemo[] { const before = slots.map((slot) => slot.read(link.template)); - const memos: SlotMemo[] = slots.map((slot, i) => ({ object: link.template, slot, previous: before[i]! })); + const memos: SlotMemo[] = slots.map((slot, i) => ({ + object: link.template, + slot, + previous: before[i]!, + })); for (const { object } of instancesOf(project, link.template.id)) { slots.forEach((slot, i) => { if (slot.read(object) !== before[i]) return; @@ -84,7 +93,11 @@ export class ApplyOverrideToAssetCommand implements Command { private previousOverride: PropertyValue | undefined; private templateMemos: SlotMemo[] = []; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) { + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly key: string, + ) { this.label = `Apply ${key} to asset`; } @@ -116,7 +129,11 @@ export class ApplyOverrideToContainerCommand implements Command { readonly label: string; private memos: SlotMemo[] = []; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) { + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly key: string, + ) { this.label = `Apply ${key} to container`; } @@ -136,7 +153,11 @@ export class RevertOverrideToTemplateCommand implements Command { readonly label: string; private previous: PropertyValue | undefined; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) { + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly key: string, + ) { this.label = `Revert ${key}`; } @@ -160,7 +181,11 @@ export class ApplyPositionToContainerCommand implements Command { readonly label: string; private memos: SlotMemo[] = []; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) { + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly axis?: Axis, + ) { this.label = axis ? `Apply ${axis} to container` : 'Apply position to container'; } @@ -168,7 +193,12 @@ export class ApplyPositionToContainerCommand implements Command { const object = requireObject(project, this.sceneId, this.objectId); const link = requireLink(project, this.sceneId, object); const axes = this.axis ? [this.axis] : ALL_AXES; - this.memos = applyToTemplate(project, link, axes.map(positionSlot), axes.map((a) => object.position[a])); + this.memos = applyToTemplate( + project, + link, + axes.map(positionSlot), + axes.map((a) => object.position[a]), + ); } undo(): void { @@ -181,7 +211,11 @@ export class RevertPositionToTemplateCommand implements Command { readonly label: string; private previous: Position = { x: 0, y: 0, z: 0 }; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) { + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly axis?: Axis, + ) { this.label = axis ? `Revert ${axis}` : 'Revert position'; } diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 1a6c9ca5..76cd2998 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -104,7 +104,10 @@ export class RenameAssetCommand implements Command { readonly label = 'Rename asset'; private previousName = ''; - constructor(private readonly assetId: string, private readonly name: string) {} + constructor( + private readonly assetId: string, + private readonly name: string, + ) {} execute(project: Project): void { const asset = requireAsset(project, this.assetId); @@ -119,7 +122,11 @@ export class RenameAssetCommand implements Command { /** The half of `SetBagValueCommand` both asset property edits share: the bag is the asset's own. */ abstract class AssetPropertyCommand extends SetBagValueCommand { - protected constructor(private readonly assetId: string, key: string, label: string) { + protected constructor( + private readonly assetId: string, + key: string, + label: string, + ) { super(key, label); } @@ -134,7 +141,11 @@ abstract class AssetPropertyCommand extends SetBagValueCommand { /** Sets an asset property (coerced against its schema entry). */ export class SetAssetPropertyCommand extends AssetPropertyCommand { - constructor(assetId: string, key: string, private readonly value: PropertyValue) { + constructor( + assetId: string, + key: string, + private readonly value: PropertyValue, + ) { super(assetId, key, `Set ${key}`); } diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index cd9114ec..60e7936a 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -18,7 +18,10 @@ export class AddColliderCommand implements Command { readonly assetId = newId(); readonly objectId = newId(); - constructor(private readonly sceneId: string, private readonly targetId: string) {} + constructor( + private readonly sceneId: string, + private readonly targetId: string, + ) {} execute(project: Project): void { const scene = requireScene(project, this.sceneId); @@ -26,7 +29,11 @@ export class AddColliderCommand implements Command { const targetAsset = requireAsset(project, target.assetId); if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider'); - const asset = buildAsset('collider', nextAssetName(project, `${target.name || targetAsset.name} collider`), this.assetId); + const asset = buildAsset( + 'collider', + nextAssetName(project, `${target.name || targetAsset.name} collider`), + this.assetId, + ); if (targetAsset.type === 'sprite') { const props = resolveSceneObjectProps(targetAsset, target); asset.props.width = Number(props.width); diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index 2d3c5feb..79820e27 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -1,6 +1,13 @@ import { newId } from '../ids'; import type { ContainerAsset } from '../model/Asset'; -import { findObject, localPositionFor, subtreeIds, subtreeRoots, treeOrder, worldPosition } from '../model/hierarchy'; +import { + findObject, + localPositionFor, + subtreeIds, + subtreeRoots, + treeOrder, + worldPosition, +} from '../model/hierarchy'; import type { Position } from '../model/Properties'; import { findScene, type Project } from '../model/Project'; import { createSceneObject } from '../model/SceneObject'; @@ -25,7 +32,10 @@ export class CreateContainerCommand implements Command { readonly objectId = newId(); private previous: { id: string; parentId: string; position: Position; templateId: string }[] = []; - constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {} + constructor( + private readonly sceneId: string, + private readonly objectIds: readonly string[], + ) {} execute(project: Project): void { const scene = requireScene(project, this.sceneId); @@ -41,14 +51,25 @@ export class CreateContainerCommand implements Command { }; // The subtrees that move into the container, in tree order (parents first). - const roots = new Set(subtreeRoots(scene, selected.map((o) => o.id))); + const roots = new Set( + subtreeRoots( + scene, + selected.map((o) => o.id), + ), + ); const memberIds = subtreeIds(scene, roots); - const members = treeOrder(scene).map((e) => e.object).filter((o) => memberIds.has(o.id)); + const members = treeOrder(scene) + .map((e) => e.object) + .filter((o) => memberIds.has(o.id)); const rootParents = new Set([...roots].map((id) => findObject(scene, id)!.parentId)); const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : ''; // Template: same structure with private ids, roots relative to the centre. - const asset = buildAsset('container', nextAssetName(project, 'Container'), this.assetId) as ContainerAsset; + const asset = buildAsset( + 'container', + nextAssetName(project, 'Container'), + this.assetId, + ) as ContainerAsset; const templateIds = new Map(members.map((o) => [o.id, newId()])); for (const object of members) { const isRoot = roots.has(object.id); @@ -57,18 +78,30 @@ export class CreateContainerCommand implements Command { id: templateIds.get(object.id)!, assetId: object.assetId, name: object.name, - position: isRoot ? { x: world.x - centre.x, y: world.y - centre.y, z: world.z } : { ...object.position }, + position: isRoot + ? { x: world.x - centre.x, y: world.y - centre.y, z: world.z } + : { ...object.position }, // A view-only offset belongs to the placed object, not to the template (see container.ts). editorOffset: { x: 0, y: 0 }, overrides: { ...object.overrides }, - parentId: isRoot ? '' : templateIds.get(object.parentId) ?? '', + parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''), templateId: '', }); } // Scene: the container takes the roots' place, the members become its instance. - this.previous = members.map((o) => ({ id: o.id, parentId: o.parentId, position: { ...o.position }, templateId: o.templateId })); - const container = createSceneObject(this.objectId, this.assetId, localPositionFor(scene, containerParentId, centre), containerParentId); + this.previous = members.map((o) => ({ + id: o.id, + parentId: o.parentId, + position: { ...o.position }, + templateId: o.templateId, + })); + const container = createSceneObject( + this.objectId, + this.assetId, + localPositionFor(scene, containerParentId, centre), + containerParentId, + ); for (const object of members) { if (roots.has(object.id)) { const world = worldPosition(scene, object); diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index d2913aa7..054bde18 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -1,4 +1,11 @@ -import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph'; +import { + edgesInto, + edgesOfNode, + findNode, + type GraphEdge, + type GraphNode, + portsCompatible, +} from '../graph/Graph'; import { nodeDef } from '../graph/NodeRegistry'; import { newId } from '../ids'; import type { PropertyBag, PropertyValue } from '../model/Properties'; @@ -14,11 +21,23 @@ export class AddNodeCommand implements Command { readonly label: string; readonly node: GraphNode; - constructor(private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}) { + constructor( + private readonly assetId: string, + type: string, + x: number, + y: number, + params: PropertyBag = {}, + ) { // An unknown type is rejected in `execute`, not here: constructors run outside the // editor's error handling, so throwing here would crash instead of showing a notice. const def = nodeDef(type); - this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def?.params ?? []), ...params } }; + this.node = { + id: newId(), + type, + x: Math.round(x), + y: Math.round(y), + params: { ...defaultsOf(def?.params ?? []), ...params }, + }; this.label = def ? `Add ${def.label}` : `Add ${type}`; } @@ -38,7 +57,10 @@ export class RemoveNodeCommand implements Command { readonly label = 'Remove node'; private removed?: { node: Removed; edges: Removed[] }; - constructor(private readonly assetId: string, private readonly nodeId: string) {} + constructor( + private readonly assetId: string, + private readonly nodeId: string, + ) {} execute(project: Project): void { const graph = requireBehavior(project, this.assetId).graph; @@ -65,7 +87,12 @@ export class MoveNodeCommand implements Command { readonly label = 'Move node'; private previous = { x: 0, y: 0 }; - constructor(private readonly assetId: string, private readonly nodeId: string, private readonly x: number, private readonly y: number) {} + constructor( + private readonly assetId: string, + private readonly nodeId: string, + private readonly x: number, + private readonly y: number, + ) {} execute(project: Project): void { const node = requireNode(project, this.assetId, this.nodeId); @@ -83,7 +110,12 @@ export class MoveNodeCommand implements Command { /** Sets a node param or the inline value of an unconnected data input. */ export class SetNodeParamCommand extends SetBagValueCommand { - constructor(private readonly assetId: string, private readonly nodeId: string, key: string, private readonly value: PropertyValue) { + constructor( + private readonly assetId: string, + private readonly nodeId: string, + key: string, + private readonly value: PropertyValue, + ) { super(key, `Set ${key}`); } @@ -111,7 +143,11 @@ export class ConnectCommand implements Command { readonly edge: GraphEdge; private replaced: Removed[] = []; - constructor(private readonly assetId: string, from: { node: string; port: string }, to: { node: string; port: string }) { + constructor( + private readonly assetId: string, + from: { node: string; port: string }, + to: { node: string; port: string }, + ) { this.edge = { id: newId(), from: { ...from }, to: { ...to } }; } @@ -124,7 +160,8 @@ export class ConnectCommand implements Command { const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port); const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port); if (!out || !inp) throw new CommandError('Port not found'); - if (!portsCompatible(out.type, inp.type)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`); + if (!portsCompatible(out.type, inp.type)) + throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`); if (graph.edges.some((e) => sameEnds(e, this.edge))) throw new CommandError('Already connected'); const occupied = inp.type === 'exec' ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port); this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e)); @@ -143,7 +180,10 @@ export class DisconnectCommand implements Command { readonly label = 'Disconnect'; private removed: Removed[] = []; - constructor(private readonly assetId: string, private readonly edgeId: string) {} + constructor( + private readonly assetId: string, + private readonly edgeId: string, + ) {} execute(project: Project): void { const graph = requireBehavior(project, this.assetId).graph; @@ -157,5 +197,10 @@ export class DisconnectCommand implements Command { } function sameEnds(a: GraphEdge, b: GraphEdge): boolean { - return a.from.node === b.from.node && a.from.port === b.from.port && a.to.node === b.to.node && a.to.port === b.to.port; + return ( + a.from.node === b.from.node && + a.from.port === b.from.port && + a.to.node === b.to.node && + a.to.port === b.to.port + ); } diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index f68ff955..5cfc202b 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -2,7 +2,13 @@ import { newId } from '../ids'; import { isPlaceableType } from '../model/AssetTypes'; import { instantiateContainer } from '../model/container'; import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy'; -import { type Position, type PropertyBag, type PropertyValue, toPosition, type Vec2 } from '../model/Properties'; +import { + type Position, + type PropertyBag, + type PropertyValue, + toPosition, + type Vec2, +} from '../model/Properties'; import type { Project } from '../model/Project'; import { createSceneObject, type SceneObject } from '../model/SceneObject'; import { coerceValue } from '../properties/PropertySchema'; @@ -53,7 +59,10 @@ export class RemoveSceneObjectsCommand implements Command { readonly label: string; private removed: Removed[] = []; - constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) { + constructor( + private readonly sceneId: string, + private readonly objectIds: readonly string[], + ) { this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects'; } @@ -83,7 +92,11 @@ export class MoveSceneObjectCommand implements Command { readonly label = 'Move object'; private previous: Position = { x: 0, y: 0, z: 0 }; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly position: Position) {} + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly position: Position, + ) {} execute(project: Project): void { const object = requireObject(project, this.sceneId, this.objectId); @@ -105,7 +118,11 @@ export class SetEditorOffsetCommand implements Command { readonly label = 'Editor offset'; private previous: Vec2 = { x: 0, y: 0 }; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly offset: Vec2) {} + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly offset: Vec2, + ) {} execute(project: Project): void { const object = requireObject(project, this.sceneId, this.objectId); @@ -122,7 +139,11 @@ export class RenameSceneObjectCommand implements Command { readonly label = 'Rename object'; private previous = ''; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly name: string) {} + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly name: string, + ) {} execute(project: Project): void { const object = requireObject(project, this.sceneId, this.objectId); @@ -137,7 +158,12 @@ export class RenameSceneObjectCommand implements Command { /** The half of `SetBagValueCommand` both override edits share: the bag is the object's overrides. */ abstract class OverrideCommand extends SetBagValueCommand { - protected constructor(private readonly sceneId: string, private readonly objectId: string, key: string, label: string) { + protected constructor( + private readonly sceneId: string, + private readonly objectId: string, + key: string, + label: string, + ) { super(key, label); } @@ -152,7 +178,12 @@ abstract class OverrideCommand extends SetBagValueCommand { /** Sets a per-object override of an asset property. */ export class SetOverrideCommand extends OverrideCommand { - constructor(sceneId: string, objectId: string, key: string, private readonly value: PropertyValue) { + constructor( + sceneId: string, + objectId: string, + key: string, + private readonly value: PropertyValue, + ) { super(sceneId, objectId, key, `Override ${key}`); } @@ -180,9 +211,16 @@ export class ClearOverrideCommand extends OverrideCommand { */ export class SetParentCommand implements Command { readonly label = 'Set parent'; - private previous: { parentId: string; position: Position } = { parentId: '', position: { x: 0, y: 0, z: 0 } }; + private previous: { parentId: string; position: Position } = { + parentId: '', + position: { x: 0, y: 0, z: 0 }, + }; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly parentId: string) {} + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly parentId: string, + ) {} execute(project: Project): void { const scene = requireScene(project, this.sceneId); @@ -190,7 +228,8 @@ export class SetParentCommand implements Command { if (this.parentId) { if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent'); requireObject(project, this.sceneId, this.parentId); - if (isSelfOrAncestor(scene, this.objectId, this.parentId)) throw new CommandError('An object cannot be parented to one of its own children'); + if (isSelfOrAncestor(scene, this.objectId, this.parentId)) + throw new CommandError('An object cannot be parented to one of its own children'); } this.previous = { parentId: object.parentId, position: { ...object.position } }; const world = worldPosition(scene, object); diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index a5f0eafc..5227b17e 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -10,7 +10,11 @@ export class AddVariableCommand implements Command { readonly label: string; readonly def: PropertyDef; - constructor(private readonly assetId: string, key: string, kind: VariableKind) { + constructor( + private readonly assetId: string, + key: string, + kind: VariableKind, + ) { this.def = variableDef(key, kind); this.label = `Add variable ${key}`; } @@ -18,7 +22,8 @@ export class AddVariableCommand implements Command { execute(project: Project): void { const asset = requireBehavior(project, this.assetId); if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME); - if (asset.variables.some((v) => v.key === this.def.key)) throw new CommandError(`Variable "${this.def.key}" already exists`); + if (asset.variables.some((v) => v.key === this.def.key)) + throw new CommandError(`Variable "${this.def.key}" already exists`); asset.variables.push(this.def); asset.props[this.def.key] = this.def.default; } @@ -36,7 +41,10 @@ export class RemoveVariableCommand implements Command { private removed?: { def: Removed; value: PropertyValue | undefined }; private overrides: { holderId: string; objectId: string; value: PropertyValue }[] = []; - constructor(private readonly assetId: string, private readonly key: string) { + constructor( + private readonly assetId: string, + private readonly key: string, + ) { this.label = `Remove variable ${key}`; } @@ -75,7 +83,11 @@ export class RemoveVariableCommand implements Command { export class RenameVariableCommand implements Command { readonly label: string; - constructor(private readonly assetId: string, private readonly from: string, private readonly to: string) { + constructor( + private readonly assetId: string, + private readonly from: string, + private readonly to: string, + ) { this.label = `Rename variable ${from}`; } diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 8dee24e9..8857cd20 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -41,7 +41,12 @@ export class GraphInterpreter { * Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with * `outputs` (e.g. `{ dt }`). Returns how many event nodes fired. */ - fire(type: string, ctx: NodeContext, outputs: Record = {}, filter?: (node: GraphNode) => boolean): number { + fire( + type: string, + ctx: NodeContext, + outputs: Record = {}, + filter?: (node: GraphNode) => boolean, + ): number { let fired = 0; for (const node of this.eventNodes(type)) { if (filter && !filter(node)) continue; @@ -66,7 +71,8 @@ export class GraphInterpreter { } private execute(node: GraphNode, ctx: NodeContext): void { - if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (infinite loop?)'); + if (++this.steps > MAX_STEPS_PER_FIRE) + throw new Error('Behavior graph exceeded the step limit (infinite loop?)'); const def = requireNodeDef(node.type); if (!def.run) return; const inputs = this.collectInputs(node, ctx); @@ -99,7 +105,8 @@ export class GraphInterpreter { const def = requireNodeDef(source.type); // Exec nodes that have not run in this pulse have no value yet. if (isEventNode(def) || hasExecInput(def) || !def.run) return null; - if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)'); + if (++this.steps > MAX_STEPS_PER_FIRE) + throw new Error('Behavior graph exceeded the step limit (data cycle?)'); // Mark before evaluating so a data cycle reads null instead of recursing forever. this.outputCache.set(nodeId, {}); const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {}; diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 898aff31..445ad5f3 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -22,12 +22,20 @@ export interface EntityHandle { /** World position of a handle: its local position plus those of all ancestors. */ export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } { - return sumAlongChain(handle, (h) => h.parent, (h) => h); + return sumAlongChain( + handle, + (h) => h.parent, + (h) => h, + ); } /** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */ export function isHandleEnabled(handle: EntityHandle): boolean { - return enabledAlongChain(handle, (h) => h.props, (h) => h.parent); + return enabledAlongChain( + handle, + (h) => h.props, + (h) => h.parent, + ); } /** Where the mouse is (world units) and what it did this frame. */ @@ -83,7 +91,11 @@ export interface NodeContext { * local to `parent` (world when none). Returns null when the asset does not exist or cannot * be placed (a scene). Spawned behaviors run their `On start` on the next tick. */ - spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null; + spawn( + assetId: string, + position: { x: number; y: number; z: number }, + parent: EntityHandle | null, + ): EntityHandle | null; destroy(target: EntityHandle): void; stop(): void; /** Ends the run and starts it again from the saved scene (everything spawned is lost). */ diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 2d584a0d..614d74d7 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -88,7 +88,13 @@ export function inlineInputDef(port: PortDef): PropertyDef { case 'any': return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') }; default: - return { key: port.key, label: port.label, kind: 'number', default: asNumber(port.default ?? 0), step: 1 }; + return { + key: port.key, + label: port.label, + kind: 'number', + default: asNumber(port.default ?? 0), + step: 1, + }; } } diff --git a/src/core/graph/nodes/action/Move.ts b/src/core/graph/nodes/action/Move.ts index 55ecbbce..c2c96fbc 100644 --- a/src/core/graph/nodes/action/Move.ts +++ b/src/core/graph/nodes/action/Move.ts @@ -10,7 +10,14 @@ export const Move: NodeDef = { "Moves an object by (dx, dy) × scale × dt. Connect dt to an event's dt for per-second movement; " + 'leave it at 1 for per-frame. With "Collide with solids" on, the object\'s solid colliders stop at ' + 'other solid colliders and slide along them; blocked x/y tell which axes hit.', - inputs: [exec(), obj('object'), num('dx'), num('dy'), withDefault(num('scale'), 1), withDefault(num('dt'), 1)], + inputs: [ + exec(), + obj('object'), + num('dx'), + num('dy'), + withDefault(num('scale'), 1), + withDefault(num('dt'), 1), + ], outputs: [exec(), bool('blockedX', 'blocked x'), bool('blockedY', 'blocked y')], params: [{ key: 'collide', label: 'Collide with solids', kind: 'boolean', default: false }], run: (ctx, inputs, params) => { diff --git a/src/core/graph/nodes/action/RestartGame.ts b/src/core/graph/nodes/action/RestartGame.ts index 2bc5bdd2..8fc990c8 100644 --- a/src/core/graph/nodes/action/RestartGame.ts +++ b/src/core/graph/nodes/action/RestartGame.ts @@ -5,7 +5,8 @@ export const RestartGame: NodeDef = { type: 'RestartGame', label: 'Restart game', category: 'action', - description: 'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.', + description: + 'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.', inputs: [exec()], outputs: [], params: [], diff --git a/src/core/graph/nodes/data/Compare.ts b/src/core/graph/nodes/data/Compare.ts index 5a5c78c7..aaee54ce 100644 --- a/src/core/graph/nodes/data/Compare.ts +++ b/src/core/graph/nodes/data/Compare.ts @@ -28,7 +28,9 @@ export const Compare: NodeDef = { }, ], run: (_ctx, inputs, params) => { - const compare = OPERATORS[String(params.op)] ?? OPERATORS['==']!; + // The fallback matches the param's declared default, so a node whose setting is missing + // (an old document, a hand-written graph) behaves like a freshly created one. + const compare = OPERATORS[String(params.op)] ?? OPERATORS['<']!; return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } }; }, }; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index b1b70d0b..27b178d9 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -73,23 +73,80 @@ import { SetVariable } from './variable/SetVariable'; */ export const BUILT_IN_NODES: readonly NodeDef[] = [ // events - OnStart, OnUpdate, OnKeyDown, OnKeyUp, OnKeyHeld, OnTimer, - OnCollisionEnter, OnCollisionStay, OnCollisionExit, OnClick, + OnStart, + OnUpdate, + OnKeyDown, + OnKeyUp, + OnKeyHeld, + OnTimer, + OnCollisionEnter, + OnCollisionStay, + OnCollisionExit, + OnClick, // flow - Branch, Sequence, + Branch, + Sequence, // variables - GetVariable, SetVariable, + GetVariable, + SetVariable, // data: references and properties - Self, Owner, Parent, ObjectByName, NameOf, IsAsset, IsEnabled, GetProperty, GetPosition, + Self, + Owner, + Parent, + ObjectByName, + NameOf, + IsAsset, + IsEnabled, + GetProperty, + GetPosition, // data: literals and math - NumberLiteral, BooleanLiteral, StringLiteral, - Add, Subtract, Multiply, Divide, Min, Max, Abs, Sqrt, Clamp, Floor, Ceil, Round, Modulo, Random, Time, + NumberLiteral, + BooleanLiteral, + StringLiteral, + Add, + Subtract, + Multiply, + Divide, + Min, + Max, + Abs, + Sqrt, + Clamp, + Floor, + Ceil, + Round, + Modulo, + Random, + Time, // data: text and comparison - ToText, Join, Equals, Compare, And, Or, Not, + ToText, + Join, + Equals, + Compare, + And, + Or, + Not, // data: vectors and input - Length, Distance, Normalize, AxisInput, KeyIsDown, MousePosition, MouseButton, IsHovered, IsTouching, + Length, + Distance, + Normalize, + AxisInput, + KeyIsDown, + MousePosition, + MouseButton, + IsHovered, + IsTouching, // actions - Move, SetPosition, SetProperty, SetEnabled, SetText, Spawn, Destroy, Log, StopGame, RestartGame, + Move, + SetPosition, + SetProperty, + SetEnabled, + SetText, + Spawn, + Destroy, + Log, + StopGame, + RestartGame, ]; for (const def of BUILT_IN_NODES) registerNode(def); diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 1130c223..4af432e6 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -16,17 +16,34 @@ export const any = (key: string, label = key): PortDef => ({ key, label, type: ' export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value }); /** Outputs of the three collision events: the other body, its collider and the own collider. */ -export const collisionOutputs = (): PortDef[] => [exec(), obj('other'), obj('otherCollider', 'other collider'), obj('collider')]; +export const collisionOutputs = (): PortDef[] => [ + exec(), + obj('other'), + obj('otherCollider', 'other collider'), + obj('collider'), +]; // --- params ---------------------------------------------------------------------------------- export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' }; /** Options are empty on purpose: the graph inspector fills in the behavior's variables. */ -export const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] }; +export const variableParam: PropertyDef = { + key: 'variable', + label: 'Variable', + kind: 'enum', + default: '', + options: [], +}; /** Options are empty on purpose: the graph inspector fills in the project's placeable assets. */ -export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] }; +export const assetParam: PropertyDef = { + key: 'asset', + label: 'Asset', + kind: 'enum', + default: '', + options: [], +}; // --- node factories -------------------------------------------------------------------------- @@ -59,7 +76,11 @@ export function unaryMath(type: string, description: string, fn: (value: number) } /** A data node combining the booleans `a` and `b`. */ -export function binaryLogic(type: string, description: string, fn: (a: boolean, b: boolean) => boolean): NodeDef { +export function binaryLogic( + type: string, + description: string, + fn: (a: boolean, b: boolean) => boolean, +): NodeDef { return { type, label: type, diff --git a/src/core/graph/nodes/variable/GetVariable.ts b/src/core/graph/nodes/variable/GetVariable.ts index a98cf3f2..33bfb09a 100644 --- a/src/core/graph/nodes/variable/GetVariable.ts +++ b/src/core/graph/nodes/variable/GetVariable.ts @@ -9,5 +9,7 @@ export const GetVariable: NodeDef = { inputs: [], outputs: [any('value')], params: [variableParam], - run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }), + run: (ctx, _inputs, params) => ({ + outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null }, + }), }; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 63714d29..c2c37595 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -65,7 +65,8 @@ export interface ContainerAsset extends AssetBase { objects: SceneObject[]; } -export type Asset = SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset; +export type Asset = + SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset; export function isSceneAsset(asset: Asset): asset is SceneAsset { return asset.type === 'scene'; diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index c1fac966..25b39f15 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -1,5 +1,13 @@ /** All asset types. Scenes are assets too, but cannot be placed in scenes. */ -export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'text', 'behavior', 'collider', 'container'] as const; +export const ASSET_TYPES = [ + 'scene', + 'camera', + 'sprite', + 'text', + 'behavior', + 'collider', + 'container', +] as const; export type AssetType = (typeof ASSET_TYPES)[number]; diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 88efc374..dd164ed5 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -1,5 +1,4 @@ -import type { Asset, ContainerAsset, SceneAsset } from './Asset'; -import { isSceneAsset } from './Asset'; +import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset'; import type { SceneObject } from './SceneObject'; /** Written into every document. Reserved for format migrations – the loader does not branch on it yet. */ @@ -49,7 +48,10 @@ export interface SceneObjectRef { } /** Objects of all scenes and container templates that satisfy `predicate`, each with its holder. */ -export function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] { +export function objectsWhere( + project: Project, + predicate: (object: SceneObject) => boolean, +): SceneObjectRef[] { const result: SceneObjectRef[] = []; for (const holder of objectHolders(project)) { for (const object of holder.objects) if (predicate(object)) result.push({ holder, object }); diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts index b5524ee6..12cb05cd 100644 --- a/src/core/model/SceneObject.ts +++ b/src/core/model/SceneObject.ts @@ -37,7 +37,12 @@ export interface SceneObject { } /** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */ -export function createSceneObject(id: string, assetId: string, position: Vec2 | Position, parentId = ''): SceneObject { +export function createSceneObject( + id: string, + assetId: string, + position: Vec2 | Position, + parentId = '', +): SceneObject { return { id, assetId, diff --git a/src/core/model/container.ts b/src/core/model/container.ts index 452f4023..0a705ffd 100644 --- a/src/core/model/container.ts +++ b/src/core/model/container.ts @@ -16,7 +16,11 @@ export interface TemplateLink { * asset holds the template this object was instantiated from. Undefined for everything else * (no template id, or the link is stale). */ -export function templateLinkOf(project: Project, tree: ObjectTree, object: SceneObject): TemplateLink | undefined { +export function templateLinkOf( + project: Project, + tree: ObjectTree, + object: SceneObject, +): TemplateLink | undefined { if (!object.templateId) return undefined; for (const containerObject of chainOf(tree, object).slice(1)) { const asset = findAsset(project, containerObject.assetId); @@ -46,12 +50,19 @@ const MAX_NESTING = 8; * turn – unless the template already spells its content out (that is what a container built * from a selection containing another container does). */ -export function instantiateContainer(project: Project, asset: ContainerAsset, containerObjectId: string, depth = 0): SceneObject[] { +export function instantiateContainer( + project: Project, + asset: ContainerAsset, + containerObjectId: string, + depth = 0, +): SceneObject[] { const ids = new Map(asset.objects.map((t) => [t.id, newId()])); const result: SceneObject[] = []; for (const template of asset.objects) { const id = ids.get(template.id)!; - const parentId = template.parentId ? ids.get(template.parentId) ?? containerObjectId : containerObjectId; + const parentId = template.parentId + ? (ids.get(template.parentId) ?? containerObjectId) + : containerObjectId; result.push({ id, assetId: template.assetId, diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index 41a79eff..b97fe478 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -31,7 +31,11 @@ function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefine /** All objects below `id`, depth-first, parents before children. */ export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] { - return descendantsBelow(id, (parentId) => childrenOf(tree, parentId), (o) => o.id); + return descendantsBelow( + id, + (parentId) => childrenOf(tree, parentId), + (o) => o.id, + ); } /** The ids of `rootIds` and of everything below them – what group operations act on. */ @@ -58,7 +62,11 @@ export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] { /** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */ export function ancestorIdsOf(tree: ObjectTree, id: string): string[] { const object = findObject(tree, id); - return object ? chainOf(tree, object).slice(1).map((o) => o.id) : []; + return object + ? chainOf(tree, object) + .slice(1) + .map((o) => o.id) + : []; } /** Whether `ancestorId` is `objectId` itself or one of its ancestors. */ @@ -80,7 +88,10 @@ function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOff return sumAlongChain( object, (o) => parentOf(tree, o), - (o) => (withEditorOffset ? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z } : o.position), + (o) => + withEditorOffset + ? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z } + : o.position, ); } @@ -128,7 +139,8 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] { visit('', 0); // Objects whose parent is missing (should not happen after loading) are listed at the top level. for (const object of tree.objects) { - if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 }); + if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) + result.push({ object, depth: 0 }); } return result; } diff --git a/src/core/model/walk.ts b/src/core/model/walk.ts index be539c72..1d6b1dec 100644 --- a/src/core/model/walk.ts +++ b/src/core/model/walk.ts @@ -13,7 +13,11 @@ import type { Position } from './Properties'; */ export function* parentChain(start: T, parentOf: (item: T) => T | null | undefined): Generator { const seen = new Set(); - for (let current: T | null | undefined = start; current && !seen.has(current); current = parentOf(current)) { + for ( + let current: T | null | undefined = start; + current && !seen.has(current); + current = parentOf(current) + ) { seen.add(current); yield current; } @@ -31,7 +35,11 @@ export function hasParentCycle(start: T, parentOf: (item: T) => T | null | un * The local positions of `start` and all its ancestors summed up – the world position, as * long as the hierarchy has no rotation or scale (it has none). */ -export function sumAlongChain(start: T, parentOf: (item: T) => T | null | undefined, positionOf: (item: T) => Position): Position { +export function sumAlongChain( + start: T, + parentOf: (item: T) => T | null | undefined, + positionOf: (item: T) => Position, +): Position { const total: Position = { x: 0, y: 0, z: 0 }; for (const item of parentChain(start, parentOf)) { const position = positionOf(item); @@ -47,7 +55,11 @@ export function sumAlongChain(start: T, parentOf: (item: T) => T | null | und * back into the key its own children are looked up by, which is what lets ids (scene objects) * and object identity (entities) use the same walk. */ -export function descendantsBelow(rootKey: K, childrenOf: (key: K) => readonly T[], keyOf: (item: T) => K): T[] { +export function descendantsBelow( + rootKey: K, + childrenOf: (key: K) => readonly T[], + keyOf: (item: T) => K, +): T[] { const result: T[] = []; const visit = (key: K) => { for (const child of childrenOf(key)) { diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 7b6a0c8a..22019348 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -28,7 +28,9 @@ export type VariableKind = (typeof VARIABLE_KINDS)[number]; * same goes for everything parented below it. Assets default to on, so switching a single * instance off is an ordinary override. Scenes have no such switch (they are not placed). */ -export const COMMON_PROPS: PropertySchema = [{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }]; +export const COMMON_PROPS: PropertySchema = [ + { key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }, +]; const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key)); @@ -91,7 +93,10 @@ const ASSET_SCHEMAS: Record = { /** Type schema with the common properties in front – the effective schema of an asset type. */ const FULL_SCHEMAS: Record = Object.fromEntries( - (Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [type, type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]]]), + (Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [ + type, + type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]], + ]), ) as Record; /** Schema shared by every asset of a type (common properties first). */ diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index f8269761..01b7b907 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -25,7 +25,10 @@ export function textSize(props: PropertyBag): { w: number; h: number } { const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize)); const lines = String(props.text ?? TEXT_DEFAULTS.text).split('\n'); const longest = lines.reduce((max, line) => Math.max(max, line.length), 0); - return { w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), h: lines.length * size * LINE_HEIGHT }; + return { + w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), + h: lines.length * size * LINE_HEIGHT, + }; } /** Horizontal anchor of a text object: 0 = left edge on the position, 0.5 = centred, 1 = right. */ diff --git a/src/core/properties/camera.ts b/src/core/properties/camera.ts index 592831aa..b07de847 100644 --- a/src/core/properties/camera.ts +++ b/src/core/properties/camera.ts @@ -17,7 +17,10 @@ export interface WorldRect { */ export function cameraFrame(props: PropertyBag): { w: number; h: number } { const zoom = Math.max(0.01, Number(props.zoom ?? DEFAULTS.zoom)); - return { w: Number(props.width ?? DEFAULTS.width) / zoom, h: Number(props.height ?? DEFAULTS.height) / zoom }; + return { + w: Number(props.width ?? DEFAULTS.width) / zoom, + h: Number(props.height ?? DEFAULTS.height) / zoom, + }; } export function rectAround(cx: number, cy: number, w: number, h: number): WorldRect { diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index f60a7edf..ceddb5a2 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -19,8 +19,20 @@ export type PlaneAxis = 'x' | 'y'; /** The shape described by collider properties, centred on `centre` (a world position). */ export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape { - if (props.shape === 'circle') return { kind: 'circle', x: centre.x, y: centre.y, r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) }; - return { kind: 'box', x: centre.x, y: centre.y, w: Math.max(0, Number(props.width ?? DEFAULTS.width)), h: Math.max(0, Number(props.height ?? DEFAULTS.height)) }; + if (props.shape === 'circle') + return { + kind: 'circle', + x: centre.x, + y: centre.y, + r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)), + }; + return { + kind: 'box', + x: centre.x, + y: centre.y, + w: Math.max(0, Number(props.width ?? DEFAULTS.width)), + h: Math.max(0, Number(props.height ?? DEFAULTS.height)), + }; } /** Whether the collider only reports overlaps (true) or also blocks movement (false). */ @@ -36,7 +48,8 @@ export function colliderSize(props: PropertyBag): { w: number; h: number } { /** Whether a point lies inside a shape (used for pointer hit tests). */ export function shapeContains(shape: ColliderShape, point: Vec2): boolean { - if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r; + if (shape.kind === 'circle') + return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r; return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h; } @@ -86,7 +99,12 @@ export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAx * `axis` so it just touches `obstacle` (0 when they do not overlap). Only valid when * `mover` approached from the far side, i.e. it did not overlap before its move. */ -export function axisCorrection(mover: ColliderShape, obstacle: ColliderShape, axis: PlaneAxis, dir: 1 | -1): number { +export function axisCorrection( + mover: ColliderShape, + obstacle: ColliderShape, + axis: PlaneAxis, + dir: 1 | -1, +): number { if (!shapesOverlap(mover, obstacle)) return 0; return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis)); } diff --git a/src/core/properties/enabled.ts b/src/core/properties/enabled.ts index 116c6db1..ac5c0fa4 100644 --- a/src/core/properties/enabled.ts +++ b/src/core/properties/enabled.ts @@ -16,7 +16,11 @@ function propsEnabled(props: PropertyBag): boolean { * Whether `start` and every ancestor of it are switched on. Generic over the shape so the * model (scene objects), the viewport (draw items) and the runtime (entities) share one rule. */ -export function enabledAlongChain(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean { +export function enabledAlongChain( + start: T, + propsOf: (item: T) => PropertyBag, + parentOf: (item: T) => T | null, +): boolean { for (const item of parentChain(start, parentOf)) if (!propsEnabled(propsOf(item))) return false; return true; } diff --git a/src/core/store/HttpProjectStore.ts b/src/core/store/HttpProjectStore.ts index bd60ac73..5a40e9fd 100644 --- a/src/core/store/HttpProjectStore.ts +++ b/src/core/store/HttpProjectStore.ts @@ -1,5 +1,10 @@ import type { Project } from '../model/Project'; -import { type LoadedProject, type ProjectStore, type ProjectSummary, StaleRevisionError } from './ProjectStore'; +import { + type LoadedProject, + type ProjectStore, + type ProjectSummary, + StaleRevisionError, +} from './ProjectStore'; import { deserializeProject, serializeProject } from './serialize'; /** @@ -14,15 +19,22 @@ export class HttpProjectStore implements ProjectStore { } async load(id: string): Promise { - const body = await this.request<{ revision: number; project: unknown }>('GET', `projects/${encodeURIComponent(id)}`); + const body = await this.request<{ revision: number; project: unknown }>( + 'GET', + `projects/${encodeURIComponent(id)}`, + ); return { project: deserializeProject(body.project, id), revision: body.revision }; } async save(project: Project, revision: number): Promise { - const body = await this.request<{ revision: number }>('PUT', `projects/${encodeURIComponent(project.id)}`, { - revision, - project: serializeProject(project), - }); + const body = await this.request<{ revision: number }>( + 'PUT', + `projects/${encodeURIComponent(project.id)}`, + { + revision, + project: serializeProject(project), + }, + ); return body.revision; } @@ -46,7 +58,7 @@ export class HttpProjectStore implements ProjectStore { }); if (response.status === 204) return undefined as T; const text = await response.text(); - let data: unknown = null; + let data: unknown; try { data = text ? JSON.parse(text) : null; } catch { diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 22b3ea2e..94c7ff57 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -6,7 +6,17 @@ import type { PropertyBag, PropertyValue } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; import { hasParentCycle } from '../model/walk'; -import { coerceValue, COMMON_PROPS, isCommonProp, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema'; +import { + coerceValue, + COMMON_PROPS, + isCommonProp, + type PropertyDef, + type PropertySchema, + schemaFor, + VARIABLE_KINDS, + variableDef, + type VariableKind, +} from '../properties/PropertySchema'; /** * Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are @@ -44,7 +54,14 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { if (type === 'scene' || type === 'container') { assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) }); } else if (type === 'behavior') { - assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) }); + assets.push({ + id, + type, + name, + props, + variables: variables.get(id) ?? [], + graph: normalizeGraph(a.graph), + }); } else { assets.push({ id, type, name, props }); } @@ -107,12 +124,14 @@ function normalizeVariables(raw: unknown): PropertyDef[] { for (const item of Array.isArray(raw) ? raw : []) { const v = asRecord(item); const key = typeof v.key === 'string' ? v.key : ''; - const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number'; + const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) + ? (v.kind as VariableKind) + : 'number'; // Common property keys are reserved – a variable of that name would shadow the switch. if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue; seen.add(key); const def = variableDef(key, kind); - def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never; + def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue); defs.push(def); } return defs; @@ -130,7 +149,8 @@ function normalizeGraph(raw: unknown): Graph { nodeIds.add(id); const params: PropertyBag = {}; for (const [key, value] of Object.entries(asRecord(n.params))) { - if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') params[key] = value; + if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') + params[key] = value; } const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params }; graph.nodes.push(node); @@ -142,10 +162,20 @@ function normalizeGraph(raw: unknown): Graph { const to = asRecord(e.to); const id = typeof e.id === 'string' ? e.id : ''; if (!id || edgeIds.has(id)) continue; - if (typeof from.node !== 'string' || typeof from.port !== 'string' || typeof to.node !== 'string' || typeof to.port !== 'string') continue; + if ( + typeof from.node !== 'string' || + typeof from.port !== 'string' || + typeof to.node !== 'string' || + typeof to.port !== 'string' + ) + continue; if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue; edgeIds.add(id); - const edge: GraphEdge = { id, from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } }; + const edge: GraphEdge = { + id, + from: { node: from.node, port: from.port }, + to: { node: to.node, port: to.port }, + }; graph.edges.push(edge); } return graph; diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index f00e175f..3ae0ffc7 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -18,7 +18,17 @@ import type { EditorState } from './state'; /** Facts about the running world for the check scripts. */ export interface WorldSnapshot { - entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record }[]; + entities: { + objectId: string; + assetId: string; + name: string; + x: number; + y: number; + worldX: number; + worldY: number; + parent: string; + props: Record; + }[]; log: string[]; } @@ -37,7 +47,10 @@ export class PlaySession { private behaviors: BehaviorSystem | null = null; private readonly input = new Input(); - constructor(private readonly state: EditorState, private readonly host: PlayHost) {} + constructor( + private readonly state: EditorState, + private readonly host: PlayHost, + ) {} get isPlaying(): boolean { return this.world !== null; @@ -76,7 +89,11 @@ export class PlaySession { world.addSystem(this.behaviors); this.host.gameView.setWorld(world); this.host.showGame(); - this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops'); + this.state.setNotice( + world.firstOfType('camera') + ? 'Playing · edits are not saved · Esc stops' + : 'This scene has no camera – nothing is rendered · Esc stops', + ); if (world.stopRequested) this.stop(); } @@ -125,7 +142,17 @@ export class PlaySession { return { entities: world.entities.map((e) => { const w = worldPositionOf(e); - return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } }; + return { + objectId: e.objectId, + assetId: e.assetId, + name: e.name, + x: e.x, + y: e.y, + worldX: w.x, + worldY: w.y, + parent: e.parent?.objectId ?? '', + props: { ...e.props }, + }; }), log: [...world.log], }; diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 62c618c9..dfb50f3c 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -13,7 +13,11 @@ export class ProjectSession { * `beforeSwitch` runs before a different project is loaded or the current one is deleted – * a run in progress has to end first, since it holds a copy of the project being replaced. */ - constructor(private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void) {} + constructor( + private readonly state: EditorState, + private readonly store: ProjectStore, + private readonly beforeSwitch: () => void, + ) {} /** Opens the project named in the URL, else the first one on the server, else a new one. */ async boot(): Promise { @@ -25,7 +29,9 @@ export class ProjectSession { } await this.openFirstOrCreate(); } catch (error) { - this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`); + this.state.setNotice( + `Could not load project: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -69,10 +75,14 @@ export class ProjectSession { label: 'Delete this project…', danger: true, onSelect: () => - new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [ - { label: 'Cancel' }, - { label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() }, - ]).openCentered(), + new Dialog( + `Delete project "${this.state.project.name}"?`, + 'All assets and scenes are removed permanently.', + [ + { label: 'Cancel' }, + { label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() }, + ], + ).openCentered(), }, ]; new Menu(items).openBelow(anchor); @@ -85,7 +95,8 @@ export class ProjectSession { label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name, onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)), })); - if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true }); + if (items.length === 0) + items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true }); new Menu(items).openBelow(anchor); } } diff --git a/src/editor/autosave.ts b/src/editor/autosave.ts index 1bc87434..b72780a5 100644 --- a/src/editor/autosave.ts +++ b/src/editor/autosave.ts @@ -7,7 +7,11 @@ import type { EditorState } from './state'; * and never overlap; a change that arrives while a save is in flight triggers another save * afterwards. */ -export function attachAutosave(state: EditorState, store: ProjectStore, delayMs = 1000): { flush: () => Promise } { +export function attachAutosave( + state: EditorState, + store: ProjectStore, + delayMs = 1000, +): { flush: () => Promise } { let timer = 0; let saving: Promise | null = null; let pending = false; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index ccb32182..82ad0582 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -24,6 +24,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E for (const child of node.children) walk(child); }; walk(app.stage); + // TypeScript does not track the assignment inside `walk`, so it believes `found` is still + // `null` here – hence both the disable and the cast below. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!found) return null; const node = found as Text; const p = node.getGlobalPosition(); @@ -43,7 +46,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E gameScreen: (id: string) => gameView.objectScreen(id), graph: { current: () => graphEditor.currentAssetId, - portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput), + portAt: (nodeId: string, port: string, isOutput: boolean) => + graphEditor.portGlobal(nodeId, port, isOutput), nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId), center: () => centreOf(graphEditor), }, diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index b7f3fc58..4bbdbae1 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -1,10 +1,22 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; -import { AddNodeCommand, ConnectCommand, DisconnectCommand, MoveNodeCommand, RemoveNodeCommand } from '../../core/commands/graphCommands'; -import { edgesInto, edgesOutOf, type GraphEdge, type NodeDef, nodeDef, portsCompatible } from '../../core/graph'; +import { + AddNodeCommand, + ConnectCommand, + DisconnectCommand, + MoveNodeCommand, + RemoveNodeCommand, +} from '../../core/commands/graphCommands'; +import { + edgesInto, + edgesOutOf, + type GraphEdge, + type NodeDef, + nodeDef, + portsCompatible, +} from '../../core/graph'; import type { BehaviorAsset } from '../../core/model/Asset'; import { findAsset } from '../../core/model/Project'; -import type { Trace } from '../../runtime/systems/BehaviorSystem'; -import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; +import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui'; import type { EditorState } from '../state'; import { NodePalette } from './NodePalette'; @@ -47,7 +59,12 @@ export class GraphEditor extends Widget { private pan = { x: 0, y: 0 }; private zoom = 1; private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; - private dragging: { nodeId: string; start: { x: number; y: number }; pointer: { x: number; y: number }; moved: boolean } | null = null; + private dragging: { + nodeId: string; + start: { x: number; y: number }; + pointer: { x: number; y: number }; + moved: boolean; + } | null = null; private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null; /** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */ private needsFit = true; @@ -78,7 +95,10 @@ export class GraphEditor extends Widget { }); state.on('selection', () => this.followSelection()); - state.on('load', () => { this.assetId = null; this.followSelection(); }); + state.on('load', () => { + this.assetId = null; + this.followSelection(); + }); state.on('project', () => this.sync()); state.on('mode', () => this.redraw()); } @@ -183,11 +203,13 @@ export class GraphEditor extends Widget { if (e.target !== this) return; const local = this.toLocal(e.global); if (e.button === 2) { - if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y)); + if (this.assetId && this.state.mode === 'edit') + this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y)); return; } if (e.button === 0 || e.button === 1) { - if (e.button === 0 && this.assetId && this.state.mode === 'edit') this.state.select({ kind: 'asset', assetId: this.assetId }); + if (e.button === 0 && this.assetId && this.state.mode === 'edit') + this.state.select({ kind: 'asset', assetId: this.assetId }); this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; } } @@ -205,13 +227,21 @@ export class GraphEditor extends Widget { if (e.button !== 0) return; if (hit) { const p = this.toLocal(e.global); - this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y) }; + this.connecting = { + from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, + pointer: this.screenToGraph(p.x, p.y), + }; this.redraw(); return; } this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id }); const p = this.toLocal(e.global); - this.dragging = { nodeId: view.node.id, start: { x: view.node.x, y: view.node.y }, pointer: this.screenToGraph(p.x, p.y), moved: false }; + this.dragging = { + nodeId: view.node.id, + start: { x: view.node.x, y: view.node.y }, + pointer: this.screenToGraph(p.x, p.y), + moved: false, + }; this.nodeLayer.addChild(view); // raise } @@ -261,7 +291,13 @@ export class GraphEditor extends Widget { if (target && target.isOutput !== connecting.from.isOutput) { const out = connecting.from.isOutput ? connecting.from : target; const inp = connecting.from.isOutput ? target : connecting.from; - this.state.execute(new ConnectCommand(this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port })); + this.state.execute( + new ConnectCommand( + this.assetId, + { node: out.nodeId, port: out.port }, + { node: inp.nodeId, port: inp.port }, + ), + ); } } this.redraw(); @@ -282,7 +318,8 @@ export class GraphEditor extends Widget { new NodePalette((def: NodeDef) => { if (!this.assetId) return; const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y); - if (this.state.execute(command)) this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id }); + if (this.state.execute(command)) + this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id }); }).open(globalX, globalY); } @@ -290,15 +327,37 @@ export class GraphEditor extends Widget { const assetId = this.assetId; if (!assetId) return; this.state.select({ kind: 'node', assetId, nodeId: view.node.id }); - new Menu([{ label: 'Delete node', danger: true, onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)) }]).open(globalX, globalY); + new Menu([ + { + label: 'Delete node', + danger: true, + onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)), + }, + ]).open(globalX, globalY); } - private openPortMenu(view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number): void { + private openPortMenu( + view: NodeView, + port: string, + isOutput: boolean, + globalX: number, + globalY: number, + ): void { const asset = this.asset(); if (!asset) return; - const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port); + const edges = isOutput + ? edgesOutOf(asset.graph, view.node.id, port) + : edgesInto(asset.graph, view.node.id, port); if (edges.length === 0) return; - new Menu([{ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); } }]).open(globalX, globalY); + new Menu([ + { + label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, + danger: true, + onSelect: () => { + for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); + }, + }, + ]).open(globalX, globalY); } // --- drawing ----------------------------------------------------------------------------- @@ -320,7 +379,10 @@ export class GraphEditor extends Widget { return; } const playing = this.state.mode === 'play'; - this.hint.text = asset.graph.nodes.length === 0 ? `${asset.name} · right-click to add a node` : `${asset.name}${playing ? ' · live' : ''}`; + this.hint.text = + asset.graph.nodes.length === 0 + ? `${asset.name} · right-click to add a node` + : `${asset.name}${playing ? ' · live' : ''}`; const trace = playing ? this.traceProvider(asset.id) : null; const now = this.timeProvider(); @@ -362,7 +424,11 @@ export class GraphEditor extends Widget { const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any'; const valid = portsCompatible(type, inType); bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y); - this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 }); + this.edges.stroke({ + width: hot ? 4 : type === 'exec' ? 2.5 : 2, + color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, + alpha: type === 'exec' || hot ? 1 : 0.85, + }); } private syncGrid(): void { @@ -371,7 +437,14 @@ export class GraphEditor extends Widget { if (step < MIN_GRID_STEP) return; // `pan` is where the graph origin sits on screen – but the graph has no origin worth // marking, so no axis lines. - drawGrid(this.grid, { originX: this.pan.x, originY: this.pan.y, step, w: this.w, h: this.h, alpha: 0.04 }); + drawGrid(this.grid, { + originX: this.pan.x, + originY: this.pan.y, + step, + w: this.w, + h: this.h, + alpha: 0.04, + }); } // --- test hooks ----------------------------------------------------------------------------- diff --git a/src/editor/graph/NodePalette.ts b/src/editor/graph/NodePalette.ts index 658f62b1..0a68838a 100644 --- a/src/editor/graph/NodePalette.ts +++ b/src/editor/graph/NodePalette.ts @@ -47,8 +47,18 @@ export class NodePalette extends Popup { private refresh(): void { const query = this.search.value.trim().toLowerCase(); this.filtered = allNodeDefs() - .filter((d) => !query || d.label.toLowerCase().includes(query) || d.type.toLowerCase().includes(query) || d.description.toLowerCase().includes(query)) - .sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || a.label.localeCompare(b.label)); + .filter( + (d) => + !query || + d.label.toLowerCase().includes(query) || + d.type.toLowerCase().includes(query) || + d.description.toLowerCase().includes(query), + ) + .sort( + (a, b) => + CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || + a.label.localeCompare(b.label), + ); this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category }))); this.scroll.relayoutContent(); } diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 73d684ac..77537f93 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -1,7 +1,20 @@ import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js'; -import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph'; +import { + asString, + edgesInto, + type Graph, + type GraphNode, + inlineInputs, + inlineValue, + type NodeCategory, + type NodeDef, + type PortDef, + type PortType, + type Value, +} from '../../core/graph'; import type { BehaviorAsset } from '../../core/model/Asset'; import { findAsset, type Project } from '../../core/model/Project'; +import type { PropertyValue } from '../../core/model/Properties'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui'; import { keyLabel } from '../inspector/fieldFactory'; @@ -22,23 +35,34 @@ const PAD = 6; export function categoryColor(category: NodeCategory): number { const c = Theme.colors; switch (category) { - case 'event': return c.categoryEvent; - case 'flow': return c.categoryFlow; - case 'data': return c.categoryData; - case 'variable': return c.categoryVariable; - case 'action': return c.categoryAction; + case 'event': + return c.categoryEvent; + case 'flow': + return c.categoryFlow; + case 'data': + return c.categoryData; + case 'variable': + return c.categoryVariable; + case 'action': + return c.categoryAction; } } export function portColor(type: PortType): number { const c = Theme.colors; switch (type) { - case 'exec': return c.portExec; - case 'number': return c.portNumber; - case 'boolean': return c.portBoolean; - case 'string': return c.portString; - case 'object': return c.portObject; - case 'any': return c.portAny; + case 'exec': + return c.portExec; + case 'number': + return c.portNumber; + case 'boolean': + return c.portBoolean; + case 'string': + return c.portString; + case 'object': + return c.portObject; + case 'any': + return c.portAny; } } @@ -62,7 +86,10 @@ export class NodeView extends Widget { /** Data inputs that currently have a connection (their inline value is hidden). */ private connected = new Set(); - constructor(readonly node: GraphNode, readonly def: NodeDef) { + constructor( + readonly node: GraphNode, + readonly def: NodeDef, + ) { super(); this.cursor = 'grab'; this.addChild(this.box, this.ports); @@ -77,9 +104,16 @@ export class NodeView extends Widget { /** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */ sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void { - this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`); - this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key)); - const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD; + this.paramLines = this.def.params.map( + (p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`, + ); + this.connected = new Set( + inlineInputs(this.def) + .filter((p) => edgesInto(graph, node.id, p.key).length > 0) + .map((p) => p.key), + ); + const height = + TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD; this.rebuildTexts(); this.resize(NODE_WIDTH, height); this.position.set(node.x, node.y); @@ -103,8 +137,12 @@ export class NodeView extends Widget { /** Every port with its local centre – inputs on the left edge, outputs on the right. */ private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> { - for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) { - for (let i = 0; i < list.length; i++) yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) }; + for (const [list, isOutput] of [ + [this.def.inputs, false], + [this.def.outputs, true], + ] as const) { + for (let i = 0; i < list.length; i++) + yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) }; } } @@ -119,7 +157,14 @@ export class NodeView extends Widget { private rebuildTexts(): void { for (const t of this.texts) t.destroy(); this.texts.length = 0; - const add = (text: string, style: TextStyleToken, x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => { + const add = ( + text: string, + style: TextStyleToken, + x: number, + y: number, + align: 'left' | 'right' = 'left', + maxWidth = NODE_WIDTH - PAD * 2, + ) => { const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) }); node.position.set(align === 'left' ? x : x - node.width, y); this.addChild(node); @@ -141,9 +186,24 @@ export class NodeView extends Widget { const value = inlineValue(this.node, input); if (value !== null && value !== '') label += ` ${formatValue(value)}`; } - add(label, input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20); + add( + label, + input.type === 'exec' ? 'body' : 'muted', + PAD + PORT_RADIUS + 4, + rowTop, + 'left', + NODE_WIDTH / 2 + 20, + ); } - if (output) add(output.label, output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10); + if (output) + add( + output.label, + output.type === 'exec' ? 'body' : 'muted', + this.w - PAD - PORT_RADIUS - 4, + rowTop, + 'right', + NODE_WIDTH / 2 - 10, + ); } } @@ -153,16 +213,27 @@ export class NodeView extends Widget { this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node); this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category)); - this.box.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(categoryColor(this.def.category)); + this.box + .rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius) + .fill(categoryColor(this.def.category)); const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder; - this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border }); + this.box + .roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius) + .stroke({ width: this.selected || this.hot ? 2 : 1, color: border }); this.ports.clear(); const r = PORT_RADIUS; for (const { port, x, y } of this.portCentres()) { if (port.type === 'exec') { // A pentagon pointing right: the Blueprint-style exec pin. - this.ports.moveTo(x - r, y - r).lineTo(x + 1, y - r).lineTo(x + r + 1, y).lineTo(x + 1, y + r).lineTo(x - r, y + r).closePath().fill(portColor('exec')); + this.ports + .moveTo(x - r, y - r) + .lineTo(x + 1, y - r) + .lineTo(x + r + 1, y) + .lineTo(x + 1, y + r) + .lineTo(x - r, y + r) + .closePath() + .fill(portColor('exec')); } else { this.ports.circle(x, y, r).fill(portColor(port.type)); this.ports.circle(x, y, r).stroke({ width: 1, color: c.node }); @@ -172,21 +243,32 @@ export class NodeView extends Widget { } /** The value of a setting as shown on the node; references that no longer resolve get a "?". */ -function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string { +function formatParam( + param: PropertyDef, + value: PropertyValue | undefined, + asset: BehaviorAsset | null, + project: Project | null, +): string { if (value === undefined || value === '') return '—'; if (param.kind === 'key') return keyLabel(String(value)); if (param.kind === 'enum' && param.key === 'asset') { const target = project ? findAsset(project, String(value)) : undefined; return target ? target.name : `${value}?`; } - if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value)) return `${value}?`; + if ( + param.kind === 'enum' && + param.key === 'variable' && + asset && + !asset.variables.some((v) => v.key === value) + ) + return `${value}?`; return formatValue(value); } -function formatValue(value: unknown): string { +function formatValue(value: Value): string { if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2); if (typeof value === 'boolean') return value ? 'true' : 'false'; - return String(value); + return asString(value); } /** Truncates with an ellipsis so the text fits `maxWidth`. */ @@ -194,6 +276,7 @@ function fit(text: string, style: TextStyleToken, maxWidth: number): string { const s = textStyle(style); if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text; let cut = text; - while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) cut = cut.slice(0, -1); + while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) + cut = cut.slice(0, -1); return cut + '…'; } diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 5146d7e3..6ccfc120 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -4,7 +4,12 @@ import { type EditorState, sameSelection, type Selection } from '../state'; import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection'; import type { InspectorHost } from './host'; import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection'; -import { buildObjectSection, buildObjectsSection, objectSignature, refreshObjectSection } from './objectSection'; +import { + buildObjectSection, + buildObjectsSection, + objectSignature, + refreshObjectSection, +} from './objectSection'; /** * Shows the properties of the current selection. The rows are generated from the @@ -33,7 +38,12 @@ export class Inspector extends Panel { } private emptyHost(): InspectorHost { - return { state: this.state, body: new VStack({ padding: Theme.padding, gap: Theme.gap }), fields: new Map(), nameField: null }; + return { + state: this.state, + body: new VStack({ padding: Theme.padding, gap: Theme.gap }), + fields: new Map(), + nameField: null, + }; } /** @@ -80,9 +90,11 @@ export class Inspector extends Panel { } if (!s) return; const ok = - s.kind === 'asset' ? refreshAssetSection(this.host, s.assetId) - : s.kind === 'node' ? refreshNodeSection(this.host, s.assetId, s.nodeId) - : s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId); + s.kind === 'asset' + ? refreshAssetSection(this.host, s.assetId) + : s.kind === 'node' + ? refreshNodeSection(this.host, s.assetId, s.nodeId) + : s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId); if (!ok) this.rebuild(); } } diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index d4345568..2e09a805 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -1,22 +1,50 @@ -import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; +import { + RenameAssetCommand, + ResetAssetPropertyCommand, + SetAssetPropertyCommand, +} from '../../core/commands/assetCommands'; import { nextVariableName } from '../../core/commands/naming'; -import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands'; +import { + AddVariableCommand, + RemoveVariableCommand, + RenameVariableCommand, +} from '../../core/commands/variableCommands'; import type { Asset, BehaviorAsset } from '../../core/model/Asset'; import { findAsset, type Project } from '../../core/model/Project'; -import { COMMON_PROPS, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema'; +import { + COMMON_PROPS, + type PropertyDef, + schemaForAsset, + VARIABLE_KINDS, + type VariableKind, +} from '../../core/properties/PropertySchema'; import { isModified, resolveAssetProps } from '../../core/properties/resolve'; import { Button, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; import { previewingHandlers } from './previewHandlers'; -const VARIABLE_KIND_LABELS: Record = { number: 'Number', boolean: 'Boolean', text: 'Text' }; -const VARIABLE_KIND_MARKS: Record = { number: '#', boolean: '✓', text: 'Aa' }; +const VARIABLE_KIND_LABELS: Record = { + number: 'Number', + boolean: 'Boolean', + text: 'Text', +}; +// Keyed by the wide `PropertyDef['kind']` on purpose: `variables` is a `PropertyDef[]`, so a +// document could carry a kind that is not a VariableKind – that renders as '?' rather than blank. +const VARIABLE_KIND_MARKS: Partial> = { + number: '#', + boolean: '✓', + text: 'Aa', +}; /** Structure-defining facts of an asset selection: the schema keys and kinds. */ export function assetSignature(project: Project, assetId: string): string { const asset = findAsset(project, assetId); - return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing'; + return asset + ? schemaForAsset(asset) + .map((d) => `${d.key}:${d.kind}`) + .join(',') + : 'missing'; } /** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */ @@ -58,11 +86,21 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void { const values = resolveAssetProps(asset); for (const def of schema) { - const row = createFieldRow(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def), () => { - const target = findAsset(host.state.project, asset.id); - if (!target || !isModified(target, def.key)) return []; - return [{ label: 'Reset to default', onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)) }]; - }); + const row = createFieldRow( + def, + values[def.key] ?? def.default, + assetHandlers(host, asset.id, def), + () => { + const target = findAsset(host.state.project, asset.id); + if (!target || !isModified(target, def.key)) return []; + return [ + { + label: 'Reset to default', + onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)), + }, + ]; + }, + ); row.setModified(isModified(asset, def.key)); host.fields.set(def.key, row); host.body.add(row.row); @@ -90,7 +128,8 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { label: VARIABLE_KIND_LABELS[kind], onSelect: () => { const current = findAsset(host.state.project, asset.id); - if (current?.type === 'behavior') host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind)); + if (current?.type === 'behavior') + host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind)); }, })); new Menu(items).openBelow(add); @@ -103,7 +142,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { for (const def of asset.variables) { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; - const mark = new Label(VARIABLE_KIND_MARKS[def.kind as VariableKind] ?? '?', { style: 'small', align: 'center' }); + const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' }); mark.minWidth = 18; row.add(mark); row.add( @@ -117,7 +156,13 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { const field = createField(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def)); row.add(field.widget, field.grow ? 1 : 0); if (!field.grow) row.add(new Spacer(), 1); - row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)) })); + row.add( + new Button('×', { + variant: 'ghost', + width: Theme.controlHeight, + onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)), + }), + ); host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} }); host.body.add(row); } diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 4bb1894a..0dc12dc4 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -1,6 +1,19 @@ import type { PropertyValue } from '../../core/model/Properties'; import type { PropertyDef } from '../../core/properties/PropertySchema'; -import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, Spacer, TextField, Theme, ui, type Widget } from '../../ui'; +import { + Button, + Checkbox, + ColorField, + HStack, + Label, + Menu, + NumberField, + Spacer, + TextField, + Theme, + ui, + type Widget, +} from '../../ui'; export interface FieldHandlers { /** Live value while dragging/typing – apply as a preview, no undo entry. */ @@ -54,7 +67,13 @@ export function labelledRow(text: string): { row: HStack; label: Label } { * its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry * means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾). */ -export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions): FieldRow { +export function createFieldRow( + def: PropertyDef, + value: PropertyValue, + handlers: FieldHandlers, + reset?: FieldActions, + apply?: FieldActions, +): FieldRow { const { row, label } = labelledRow(def.label); const field = createField(def, value, handlers); row.add(field.widget, field.grow ? 1 : 0); @@ -141,10 +160,13 @@ export function createField(def: PropertyDef, value: PropertyValue, handlers: Fi return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) }; } case 'enum': { - const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—'); + const labelOf = (v: PropertyValue) => + def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—'); const button = new Button(`${labelOf(value)} ▾`); button.onClick = () => { - new Menu(def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) }))).openBelow(button); + new Menu( + def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })), + ).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => button.setText(`${labelOf(v)} ▾`) }; } @@ -160,7 +182,10 @@ class KeyCaptureButton extends Button { private key: string; private capturing = false; - constructor(key: string, private readonly onCapture: (key: string) => void) { + constructor( + key: string, + private readonly onCapture: (key: string) => void, + ) { super(keyLabel(key)); this.key = key; this.onClick = () => { diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index f9e859bb..2d7f7f68 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -16,7 +16,11 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string) const node = asset.graph.nodes.find((n) => n.id === nodeId); if (!node) return 'missing'; const def = nodeDef(node.type); - const connected = def ? inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0).map((p) => p.key) : []; + const connected = def + ? inlineInputs(def) + .filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0) + .map((p) => p.key) + : []; return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`; } @@ -35,7 +39,11 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s if (def.params.length > 0) addTitle(host, 'SETTINGS'); for (const param of def.params) { const effective = resolveParamDef(host.state.project, param, asset); - const row = createFieldRow(effective, node.params[param.key] ?? effective.default, paramHandlers(host, assetId, nodeId, param.key)); + const row = createFieldRow( + effective, + node.params[param.key] ?? effective.default, + paramHandlers(host, assetId, nodeId, param.key), + ); host.fields.set(`param:${param.key}`, row); host.body.add(row.row); } @@ -44,13 +52,22 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s if (free.length > 0) addTitle(host, 'INPUTS (unconnected)'); for (const port of free) { const inline = inlineInputDef(port); - const row = createFieldRow(inline, node.params[port.key] ?? inline.default, paramHandlers(host, assetId, nodeId, port.key)); + const row = createFieldRow( + inline, + node.params[port.key] ?? inline.default, + paramHandlers(host, assetId, nodeId, port.key), + ); host.fields.set(`input:${port.key}`, row); host.body.add(row.row); } addGap(host); - host.body.add(new Button('Delete node', { variant: 'danger', onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)) })); + host.body.add( + new Button('Delete node', { + variant: 'danger', + onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)), + }), + ); } /** Pushes the node's current params into the rows. False when the node is gone (rebuild). */ @@ -60,7 +77,10 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: if (!node || !def) return false; for (const [key, row] of host.fields) { const [scope, name] = key.split(':') as ['param' | 'input', string]; - const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : inlineInputDef(def.inputs.find((p) => p.key === name)!).default; + const fallback = + scope === 'param' + ? def.params.find((p) => p.key === name)?.default + : inlineInputDef(def.inputs.find((p) => p.key === name)!).default; row.setValue(node.params[name] ?? fallback ?? ''); } return true; @@ -76,7 +96,12 @@ function resolveParamDef(project: Project, param: PropertyDef, asset: Asset): Pr return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) }; } if (param.key === 'asset') { - return { ...param, options: project.assets.filter((a) => isPlaceableType(a.type)).map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })) }; + return { + ...param, + options: project.assets + .filter((a) => isPlaceableType(a.type)) + .map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })), + }; } return param; } diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 29a6fcfe..cfd9802b 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -20,8 +20,17 @@ import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierar import type { Axis, PropertyValue } from '../../core/model/Properties'; import { findAsset, findScene, type Project } from '../../core/model/Project'; import type { SceneObject } from '../../core/model/SceneObject'; -import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; -import { deviatesFromAsset, deviatesFromTemplate, resolveSceneObjectProps } from '../../core/properties/resolve'; +import { + COMMON_PROPS, + isCommonProp, + type PropertyDef, + schemaForAsset, +} from '../../core/properties/PropertySchema'; +import { + deviatesFromAsset, + deviatesFromTemplate, + resolveSceneObjectProps, +} from '../../core/properties/resolve'; import { Button, Menu, type MenuItem, TextField } from '../../ui'; import { addCollider, createContainer, removeObjects } from '../actions'; import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory'; @@ -34,13 +43,23 @@ const OFFSET_AXES = ['x', 'y'] as const; const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`; /** Structure-defining facts of an object selection: parent, template link and schema. */ -export function objectSignature(project: Project, sceneId: string, objectIds: readonly string[], objectId: string): string { +export function objectSignature( + project: Project, + sceneId: string, + objectIds: readonly string[], + objectId: string, +): string { if (objectIds.length > 1) return `multi|${objectIds.join(',')}`; const scene = findScene(project, sceneId); const object = scene && findObject(scene, objectId); const asset = object && findAsset(project, object.assetId); if (!object || !asset) return 'missing'; - return `${object.parentId}|${object.templateId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(','); + return ( + `${object.parentId}|${object.templateId}|` + + schemaForAsset(asset) + .map((d) => `${d.key}:${d.kind}`) + .join(',') + ); } /** Several objects: what they are, and the actions that apply to all of them at once. */ @@ -57,11 +76,18 @@ export function buildObjectsSection(host: InspectorHost, sceneId: string, object addNote(host, 'Ctrl-click adds or removes objects.', 'small'); addNote(host, 'Dragging one in the scene moves all of them.', 'small'); addGap(host); - host.body.add(new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) })); + host.body.add( + new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }), + ); addNote(host, 'Groups the selection into a reusable container', 'small'); addNote(host, 'asset placed at its centre (Ctrl+G).', 'small'); addGap(host); - host.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(host.state, sceneId, objectIds) })); + host.body.add( + new Button('Remove from scene', { + variant: 'danger', + onClick: () => removeObjects(host.state, sceneId, objectIds), + }), + ); } /** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */ @@ -81,7 +107,13 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI host.body.add(nameRow.row); const assetRow = labelledRow('Asset'); - assetRow.row.add(new Button(asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }) }), 1); + assetRow.row.add( + new Button(asset.name, { + variant: 'ghost', + onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }), + }), + 1, + ); host.body.add(assetRow.row); host.body.add(parentRow(host, scene, object)); @@ -90,12 +122,19 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI const link = templateLinkOf(host.state.project, scene, object); if (link) { const linkRow = labelledRow('Part of'); - linkRow.row.add(new Button(link.asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }) }), 1); + linkRow.row.add( + new Button(link.asset.name, { + variant: 'ghost', + onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }), + }), + 1, + ); host.body.add(linkRow.row); } // The master switch, right where it is looked for: above everything type-specific. - for (const def of COMMON_PROPS) propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object)); + for (const def of COMMON_PROPS) + propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object)); addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION'); for (const axis of POSITION_AXES) { @@ -107,8 +146,26 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI read: (o) => o.position, write: (o, v) => (o.position = v), command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v), - reset: () => (moved() ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }] : []), - apply: () => (moved() ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] : []), + reset: () => + moved() + ? [ + { + label: 'Reset to container', + onSelect: () => + host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)), + }, + ] + : [], + apply: () => + moved() + ? [ + { + label: 'Apply to container', + onSelect: () => + host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)), + }, + ] + : [], }); } @@ -126,7 +183,15 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI reset: () => { const current = currentObject(host.state, sceneId, objectId); if (!current || current.editorOffset[axis] === 0) return []; - return [{ label: 'Reset to 0', onSelect: () => host.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 })) }]; + return [ + { + label: 'Reset to 0', + onSelect: () => + host.state.execute( + new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }), + ), + }, + ]; }, }); } @@ -135,7 +200,8 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI const schema = schemaForAsset(asset); const values = resolveSceneObjectProps(asset, object); const own = schema.filter((def) => !isCommonProp(def.key)); - if (own.length > 0 || asset.type === 'behavior') addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES'); + if (own.length > 0 || asset.type === 'behavior') + addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES'); if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.'); for (const def of own) propertyRow(host, sceneId, objectId, def, values); markObjectRows(host, object, asset, link); @@ -149,7 +215,12 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI if (asset.type !== 'collider') { addGap(host); - host.body.add(new Button('+ Add collider', { variant: 'ghost', onClick: () => addCollider(host.state, sceneId, objectId) })); + host.body.add( + new Button('+ Add collider', { + variant: 'ghost', + onClick: () => addCollider(host.state, sceneId, objectId), + }), + ); } } @@ -184,7 +255,13 @@ interface ComponentSpec { * The row edits one axis, the command carries the whole vector: the other components come * from the target after the preview was taken back, so they are the pre-preview ones. */ -function componentRow(host: InspectorHost, sceneId: string, objectId: string, value: number, spec: ComponentSpec): void { +function componentRow( + host: InspectorHost, + sceneId: string, + objectId: string, + value: number, + spec: ComponentSpec, +): void { const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) }); const row = createFieldRow( { key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 }, @@ -214,7 +291,13 @@ function componentRow(host: InspectorHost, sceneId: string, ob * override is what makes the object follow the asset again: the template is only read when * the container is placed, never at draw time. */ -function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def: PropertyDef, values: Record): FieldRow { +function propertyRow( + host: InspectorHost, + sceneId: string, + objectId: string, + def: PropertyDef, + values: Record, +): FieldRow { const bases = () => deviations(host.state, sceneId, objectId, def.key); const row = createFieldRow( def, @@ -229,15 +312,46 @@ function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def () => { const { template, asset } = bases(); return [ - ...(template ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []), - ...(asset ? [{ label: 'Reset to asset', onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []), + ...(template + ? [ + { + label: 'Reset to container', + onSelect: () => + host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)), + }, + ] + : []), + ...(asset + ? [ + { + label: 'Reset to asset', + onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)), + }, + ] + : []), ]; }, () => { const { template, asset } = bases(); return [ - ...(template ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []), - ...(asset ? [{ label: 'Apply to asset', onSelect: () => host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []), + ...(template + ? [ + { + label: 'Apply to container', + onSelect: () => + host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)), + }, + ] + : []), + ...(asset + ? [ + { + label: 'Apply to asset', + onSelect: () => + host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)), + }, + ] + : []), ]; }, ); @@ -256,11 +370,16 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) const parent = findObject(scene, object.parentId); const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`); button.onClick = () => { - const items: MenuItem[] = [{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) }]; + const items: MenuItem[] = [ + { label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) }, + ]; for (const { object: candidate, depth } of treeOrder(scene)) { if (isSelfOrAncestor(scene, object.id, candidate.id)) continue; const name = `${' '.repeat(depth)}${nameOf(candidate)}`; - items.push({ label: candidate.id === object.parentId ? `● ${name}` : name, onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)) }); + items.push({ + label: candidate.id === object.parentId ? `● ${name}` : name, + onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)), + }); } new Menu(items).openBelow(button); }; @@ -273,11 +392,22 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) * the template (position per axis, overrides), for any other object one from the asset. The * editor offset has one base only – no offset at all. */ -function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset, link: TemplateLink | undefined): void { - for (const axis of POSITION_AXES) host.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]); - for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0); +function markObjectRows( + host: InspectorHost, + object: SceneObject, + asset: Asset, + link: TemplateLink | undefined, +): void { + for (const axis of POSITION_AXES) + host.fields + .get(axis) + ?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]); + for (const axis of OFFSET_AXES) + host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0); for (const def of schemaForAsset(asset)) { - const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key); + const deviates = link + ? deviatesFromTemplate(object, link.template, def.key) + : deviatesFromAsset(object, asset, def.key); host.fields.get(def.key)?.setModified(deviates); } } @@ -287,7 +417,12 @@ function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset, * differs: the template of the container the object belongs to, and the asset behind it. * Evaluated on demand so a menu never acts on a stale snapshot. */ -function deviations(state: InspectorHost['state'], sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } { +function deviations( + state: InspectorHost['state'], + sceneId: string, + objectId: string, + key: string, +): { template: boolean; asset: boolean } { const scene = findScene(state.project, sceneId); const object = scene && findObject(scene, objectId); const asset = object && findAsset(state.project, object.assetId); @@ -300,14 +435,23 @@ function deviations(state: InspectorHost['state'], sceneId: string, objectId: st } /** Whether the object sits somewhere else on `axis` than its container template says. */ -function positionMoved(state: InspectorHost['state'], sceneId: string, objectId: string, axis: Axis): boolean { +function positionMoved( + state: InspectorHost['state'], + sceneId: string, + objectId: string, + axis: Axis, +): boolean { const scene = findScene(state.project, sceneId); const object = scene && findObject(scene, objectId); const link = scene && object && templateLinkOf(state.project, scene, object); return Boolean(link) && object!.position[axis] !== link!.template.position[axis]; } -function currentObject(state: InspectorHost['state'], sceneId: string, objectId: string): SceneObject | undefined { +function currentObject( + state: InspectorHost['state'], + sceneId: string, + objectId: string, +): SceneObject | undefined { const scene = findScene(state.project, sceneId); return scene && findObject(scene, objectId); } diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts index 78c19761..a7399bdc 100644 --- a/src/editor/inspector/previewHandlers.ts +++ b/src/editor/inspector/previewHandlers.ts @@ -27,7 +27,10 @@ export interface PreviewedValue { * value from before the preview is written back first – including "the key did not exist", * via `remove` – and only then is the command executed. */ -export function previewingHandlers(host: InspectorHost, spec: PreviewedValue): FieldHandlers { +export function previewingHandlers( + host: InspectorHost, + spec: PreviewedValue, +): FieldHandlers { const toModel = spec.toModel ?? ((input: PropertyValue) => input as unknown as Model); let previewed = false; let before: Model | undefined; diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 7f3625fb..e5bd170e 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -13,12 +13,21 @@ export class AssetPanel extends Panel { private readonly list = new ListView(); private readonly scroll = new ScrollContainer(); - constructor(private readonly state: EditorState, private readonly placementPoint: () => Vec2) { + constructor( + private readonly state: EditorState, + private readonly placementPoint: () => Vec2, + ) { super({ title: 'Assets', padding: 0, gap: 0 }); this.minWidth = 160; const add = new Button('+', { variant: 'ghost', width: 28 }); - add.onClick = () => new Menu(ASSET_TYPES.map((type) => ({ label: ASSET_TYPE_LABELS[type], onSelect: () => this.createAsset(type) }))).openBelow(add); + add.onClick = () => + new Menu( + ASSET_TYPES.map((type) => ({ + label: ASSET_TYPE_LABELS[type], + onSelect: () => this.createAsset(type), + })), + ).openBelow(add); this.header.add(add); this.list.emptyText = 'No assets – press +'; @@ -81,9 +90,17 @@ export class AssetPanel extends Panel { if (!asset) return; const items: MenuItem[] = []; if (asset.type === 'scene') { - items.push({ label: 'Set as active scene', onSelect: () => this.state.execute(new SetActiveSceneCommand(id)), disabled: this.state.project.activeSceneId === id }); + items.push({ + label: 'Set as active scene', + onSelect: () => this.state.execute(new SetActiveSceneCommand(id)), + disabled: this.state.project.activeSceneId === id, + }); } else { - items.push({ label: 'Place in scene', onSelect: () => this.place(id), disabled: !this.state.activeScene }); + items.push({ + label: 'Place in scene', + onSelect: () => this.place(id), + disabled: !this.state.activeScene, + }); } items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) }); new Menu(items).open(x, y); diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index af8166f9..2a4ac0b4 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -49,7 +49,10 @@ export class HierarchyPanel extends Panel { state.on('project', () => this.refresh()); // A different project brings different objects – start folded again. - state.on('load', () => { this.expanded.clear(); this.refresh(); }); + state.on('load', () => { + this.expanded.clear(); + this.refresh(); + }); state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection())); this.refresh(); } @@ -88,13 +91,31 @@ export class HierarchyPanel extends Panel { const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id]; const many = selected.length > 1; new Menu([ - { label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) }, - { label: 'Add collider', disabled: !type || type === 'collider' || many, onSelect: () => addCollider(state, scene.id, id) }, - { label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) }, - { label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) }, + { + label: 'Select asset', + onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }), + }, + { + label: 'Add collider', + disabled: !type || type === 'collider' || many, + onSelect: () => addCollider(state, scene.id, id), + }, + { + label: many ? `Create container from ${selected.length} objects` : 'Create container', + onSelect: () => createContainer(state, scene.id, selected), + }, + { + label: 'Unparent (move to top level)', + disabled: !object?.parentId || many, + onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')), + }, { label: 'Expand all', onSelect: () => this.expandAll() }, { label: 'Collapse all', onSelect: () => this.collapseAll() }, - { label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) }, + { + label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', + danger: true, + onSelect: () => removeObjects(state, scene.id, selected), + }, ]).open(x, y); } diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index 18e3d254..b6f13ca1 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -34,7 +34,10 @@ export class Toolbar extends HStack { private readonly notice: Label; private readonly fps: Label; - constructor(private readonly state: EditorState, actions: ToolbarActions) { + constructor( + private readonly state: EditorState, + actions: ToolbarActions, + ) { super({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); this.minHeight = Theme.controlHeight + Theme.padding * 2; this.addChildAt(this.bg, 0); @@ -53,7 +56,10 @@ export class Toolbar extends HStack { this.add(new Spacer(Theme.padding)); this.add(new Label('Scene', { style: 'muted' })); - this.sceneButton = new Button('—', { width: 160, onClick: () => actions.openSceneMenu(this.sceneButton) }); + this.sceneButton = new Button('—', { + width: 160, + onClick: () => actions.openSceneMenu(this.sceneButton), + }); this.add(this.sceneButton); this.add(new Spacer(Theme.padding)); @@ -63,7 +69,11 @@ export class Toolbar extends HStack { this.add(this.redoButton); this.add(new Spacer(), 1); - this.playButton = new Button('▶ Play', { variant: 'primary', width: 110, onClick: () => actions.togglePlay() }); + this.playButton = new Button('▶ Play', { + variant: 'primary', + width: 110, + onClick: () => actions.togglePlay(), + }); this.add(this.playButton); this.add(new Spacer(), 1); diff --git a/src/editor/state.ts b/src/editor/state.ts index b6481f45..a0cad990 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -194,7 +194,10 @@ export class EditorState { select(selection: Selection): void { // Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`. - if (selection?.kind === 'object' && !selection.objectIds) selection = objectSelection(selection.sceneId, [selection.objectId]); + // The type says `objectIds` is always there – untyped callers are exactly why this guard exists. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (selection?.kind === 'object' && !selection.objectIds) + selection = objectSelection(selection.sceneId, [selection.objectId]); if (sameSelection(this.selection, selection)) return; this.selection = selection; this.emit('selection'); @@ -204,7 +207,9 @@ export class EditorState { toggleObject(sceneId: string, objectId: string): void { const s = this.selection; const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : []; - const next = current.includes(objectId) ? current.filter((id) => id !== objectId) : [...current, objectId]; + const next = current.includes(objectId) + ? current.filter((id) => id !== objectId) + : [...current, objectId]; this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId)); } @@ -227,7 +232,8 @@ export class EditorState { const asset = findAsset(this.project, s.assetId); // Fall back to the behavior asset so the graph stays open when its node vanished (undo). if (!asset || asset.type !== 'behavior') this.select(null); - else if (!asset.graph.nodes.some((n) => n.id === s.nodeId)) this.select({ kind: 'asset', assetId: s.assetId }); + else if (!asset.graph.nodes.some((n) => n.id === s.nodeId)) + this.select({ kind: 'asset', assetId: s.assetId }); } } @@ -262,7 +268,12 @@ export function sameSelection(a: Selection, b: Selection): boolean { if (!a || !b || a.kind !== b.kind) return false; if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId; if (a.kind === 'object' && b.kind === 'object') { - return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.length === b.objectIds.length && a.objectIds.every((id, i) => id === b.objectIds[i]); + return ( + a.sceneId === b.sceneId && + a.objectId === b.objectId && + a.objectIds.length === b.objectIds.length && + a.objectIds.every((id, i) => id === b.objectIds[i]) + ); } if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId; return false; diff --git a/src/main.ts b/src/main.ts index fdf72c76..c879a774 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,7 +35,10 @@ async function main(): Promise { }); } -window.addEventListener('error', (event) => console.log('[uncaught]', event.error?.stack ?? event.message)); +window.addEventListener('error', (event) => { + const error: unknown = event.error; + console.error('[uncaught]', error instanceof Error ? error.stack : event.message); +}); main().catch((error) => { console.error(error); diff --git a/src/playground.ts b/src/playground.ts index 192e67f6..fe39e66f 100644 --- a/src/playground.ts +++ b/src/playground.ts @@ -13,7 +13,7 @@ import { ScrollContainer, TextField, Theme, - VStack, + type VStack, toHex, } from './ui'; @@ -33,24 +33,32 @@ export function mountPlayground(app: Application, root: VStack): void { // Column 1: inputs const inputs = new Panel({ title: 'Inputs' }); inputs.content.add(new Label('Text field')); - inputs.content.add(new TextField('Crate', { - placeholder: 'Name', - onInput: (v) => log(`text input: ${v}`), - onChange: (v) => log(`text change: ${v}`), - })); + inputs.content.add( + new TextField('Crate', { + placeholder: 'Name', + onInput: (v) => log(`text input: ${v}`), + onChange: (v) => log(`text change: ${v}`), + }), + ); inputs.content.add(new Label('Number field (drag to scrub)')); - inputs.content.add(new NumberField(100, { - min: 1, - step: 1, - onInput: (v) => log(`number input: ${v}`), - onChange: (v) => log(`number change: ${v}`), - })); - inputs.content.add(new NumberField(1, { min: 0.1, max: 10, step: 0.1, onChange: (v) => log(`zoom change: ${v}`) })); + inputs.content.add( + new NumberField(100, { + min: 1, + step: 1, + onInput: (v) => log(`number input: ${v}`), + onChange: (v) => log(`number change: ${v}`), + }), + ); + inputs.content.add( + new NumberField(1, { min: 0.1, max: 10, step: 0.1, onChange: (v) => log(`zoom change: ${v}`) }), + ); inputs.content.add(new Label('Color field')); - inputs.content.add(new ColorField(0xe0553f, { - onInput: (c) => log(`color input: ${toHex(c)}`), - onChange: (c) => log(`color change: ${toHex(c)}`), - })); + inputs.content.add( + new ColorField(0xe0553f, { + onInput: (c) => log(`color input: ${toHex(c)}`), + onChange: (c) => log(`color change: ${toHex(c)}`), + }), + ); const checkRow = new HStack({ gap: Theme.gap, align: 'center' }); const checkbox = new Checkbox(true); checkbox.onChange = (v) => log(`checkbox: ${v}`); @@ -83,25 +91,30 @@ export function mountPlayground(app: Application, root: VStack): void { ]).openBelow(menuButton); }; actions.content.add(menuButton); - actions.content.add(new Button('Open dialog…', { - onClick: () => new Dialog('Delete asset?', 'Every placed instance is removed as well.', [ - { label: 'Cancel' }, - { label: 'Delete', danger: true, onClick: () => log('dialog: deleted') }, - ]).openCentered(), - })); + actions.content.add( + new Button('Open dialog…', { + onClick: () => + new Dialog('Delete asset?', 'Every placed instance is removed as well.', [ + { label: 'Cancel' }, + { label: 'Delete', danger: true, onClick: () => log('dialog: deleted') }, + ]).openCentered(), + }), + ); row.add(actions, 1); // Column 3: list in a scroll container const listPanel = new Panel({ title: 'List (scroll)', padding: 0 }); const scroll = new ScrollContainer(); const list = new ListView(); - list.setItems(Array.from({ length: 40 }, (_, i) => ({ - id: `item-${i}`, - label: `Item ${i + 1}${i % 7 === 0 ? ' with a rather long name that must truncate' : ''}`, - hint: i % 3 === 0 ? 'sprite' : i % 3 === 1 ? 'camera' : 'scene', - swatch: i % 3 === 0 ? (0x3fa0e0 + i * 0x040201) & 0xffffff : undefined, - depth: i % 5 === 4 ? 1 : 0, - }))); + list.setItems( + Array.from({ length: 40 }, (_, i) => ({ + id: `item-${i}`, + label: `Item ${i + 1}${i % 7 === 0 ? ' with a rather long name that must truncate' : ''}`, + hint: i % 3 === 0 ? 'sprite' : i % 3 === 1 ? 'camera' : 'scene', + swatch: i % 3 === 0 ? (0x3fa0e0 + i * 0x040201) & 0xffffff : undefined, + depth: i % 5 === 4 ? 1 : 0, + })), + ); list.onSelect = (id) => log(`list select: ${id}`); list.onActivate = (id) => log(`list activate: ${id}`); list.onContextMenu = (id, x, y) => diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 4c8a0bcd..d5893213 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -33,7 +33,14 @@ export class World { * Mouse state in world units, fed by the game view (the only place with the camera * transform). `pressed`/`released` last for one tick, like the keyboard edges. */ - readonly pointer: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false }; + readonly pointer: PointerState = { + x: 0, + y: 0, + inside: false, + down: false, + pressed: false, + released: false, + }; /** Messages logged by behaviors (Log node), newest last. */ readonly log: string[] = []; private readonly systems: System[] = []; @@ -41,7 +48,10 @@ export class World { /** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */ private structureChanged = false; - private constructor(readonly project: Project, readonly sceneId: string) {} + private constructor( + readonly project: Project, + readonly sceneId: string, + ) {} /** Builds a world from the given scene. Throws when the scene does not exist. */ static instantiate(project: Project, sceneId: string): World { @@ -75,11 +85,12 @@ export class World { if (!asset || !isPlaceableType(asset.type)) continue; keep.add(this.materialize(object, asset)); } - for (let i = this.entities.length - 1; i >= 0; i--) if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1); + for (let i = this.entities.length - 1; i >= 0; i--) + if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1); // Link parents once every entity exists (storage order is not guaranteed to be parent-first). for (const entity of this.entities) { const object = findObject(scene, entity.objectId); - entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null; + entity.parent = object?.parentId ? (this.findByObjectId(object.parentId) ?? null) : null; } } @@ -87,7 +98,18 @@ export class World { private materialize(object: SceneObject, asset: Asset): Entity { let entity = this.findByObjectId(object.id); if (!entity) { - entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, type: asset.type, name: '', x: 0, y: 0, z: 0, props: {}, parent: null }; + entity = { + id: `e-${object.id}`, + objectId: object.id, + assetId: asset.id, + type: asset.type, + name: '', + x: 0, + y: 0, + z: 0, + props: {}, + parent: null, + }; this.entities.push(entity); } entity.assetId = asset.id; @@ -109,14 +131,21 @@ export class World { * during this tick already). Position is local to `parent`, world when there is none. * Returns null for unknown or unplaceable assets. */ - spawn(assetId: string, position: { x: number; y: number; z: number }, parent: Entity | null): Entity | null { + spawn( + assetId: string, + position: { x: number; y: number; z: number }, + parent: Entity | null, + ): Entity | null { const scene = findScene(this.project, this.sceneId); const asset = findAsset(this.project, assetId); if (!scene || !asset || !isPlaceableType(asset.type)) return null; const parentEntity = parent && this.entities.includes(parent) ? parent : null; const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? ''); // A container brings its content along (parented below it, in template order). - const created = [object, ...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : [])]; + const created = [ + object, + ...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : []), + ]; scene.objects.push(...created); const entity = this.materialize(object, asset); entity.parent = parentEntity; @@ -152,9 +181,11 @@ export class World { const asset = object && findAsset(this.project, entity.assetId); if (!object || !asset) continue; const p = object.position; - if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) object.position = { x: entity.x, y: entity.y, z: entity.z }; + if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) + object.position = { x: entity.x, y: entity.y, z: entity.z }; const base = resolveSceneObjectProps(asset, object); - for (const [key, value] of Object.entries(entity.props)) if (key in base && base[key] !== value) object.overrides[key] = value; + for (const [key, value] of Object.entries(entity.props)) + if (key in base && base[key] !== value) object.overrides[key] = value; } return spawned || scene.objects.length !== before; } @@ -210,8 +241,13 @@ export class World { const point = { x, y }; const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e)); const colliders = parts.filter((e) => e.type === 'collider'); - if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point)); - return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, worldPositionOf(e), point)); + if (colliders.length > 0) + return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point)); + return parts.some( + (e) => + (e.type === 'sprite' || e.type === 'text') && + footprintContains(e.type, e.props, worldPositionOf(e), point), + ); } childrenOf(entity: Entity): Entity[] { @@ -220,7 +256,11 @@ export class World { /** Everything parented below an entity, at any depth. */ descendantsOf(entity: Entity): Entity[] { - return descendantsBelow(entity, (parent) => this.childrenOf(parent), (child) => child); + return descendantsBelow( + entity, + (parent) => this.childrenOf(parent), + (child) => child, + ); } findByObjectId(objectId: string): Entity | undefined { diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 5262861b..f23bdc50 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -1,4 +1,11 @@ -import { type EntityHandle, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener } from '../../core/graph'; +import { + type EntityHandle, + GraphInterpreter, + isHandleEnabled, + type NodeContext, + type TraceListener, +} from '../../core/graph'; +import type { PropertyBag } from '../../core/model/Properties'; import { findAsset, type Project } from '../../core/model/Project'; import type { Entity } from '../Entity'; import type { World } from '../World'; @@ -56,7 +63,12 @@ export class BehaviorSystem implements System { private syncInstances(world: World): void { this.instances = this.instances.filter((i) => world.entities.includes(i.entity)); for (const entity of world.entities) { - if (entity.type !== 'behavior' || !isHandleEnabled(entity) || this.instances.some((i) => i.entity === entity)) continue; + if ( + entity.type !== 'behavior' || + !isHandleEnabled(entity) || + this.instances.some((i) => i.entity === entity) + ) + continue; const asset = findAsset(this.project, entity.assetId); if (!asset || asset.type !== 'behavior') continue; const trace: Trace = { edges: new Map(), nodes: new Map() }; @@ -73,34 +85,38 @@ export class BehaviorSystem implements System { update(world: World, dt: number): void { this.syncInstances(world); - const keyOf = (node: { params: Record }) => String(node.params.key ?? ''); + const keyOf = (node: { params: PropertyBag }) => String(node.params.key ?? ''); for (const instance of this.instances) { if (!world.entities.includes(instance.entity)) continue; // destroyed during play if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent) - this.run(instance, (i, ctx) => { - for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key); - for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key); - for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key); - for (const node of i.eventNodes('OnTimer')) { - const state = i.nodeState.get(node.id) ?? { elapsed: 0 }; - i.nodeState.set(node.id, state); - const interval = Math.max(0.01, Number(node.params.interval ?? 1)); - state.elapsed = (state.elapsed ?? 0) + dt; - while (state.elapsed >= interval) { - state.elapsed -= interval; - i.fire('OnTimer', ctx, {}, (n) => n.id === node.id); + this.run( + instance, + (i, ctx) => { + for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key); + for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key); + for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key); + for (const node of i.eventNodes('OnTimer')) { + const state = i.nodeState.get(node.id) ?? { elapsed: 0 }; + i.nodeState.set(node.id, state); + const interval = Math.max(0.01, Number(node.params.interval ?? 1)); + state.elapsed = (state.elapsed ?? 0) + dt; + while (state.elapsed >= interval) { + state.elapsed -= interval; + i.fire('OnTimer', ctx, {}, (n) => n.id === node.id); + } } - } - this.fireCollisions(instance, i, ctx); - // With an owner the click has to land on it; a behavior without one has no shape to - // be hit, so it listens to the whole game view (anywhere inside the camera frame). - const pointer = world.pointer; - const owner = instance.entity.parent; - if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) { - i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y }); - } - i.fire('OnUpdate', ctx, { dt }); - }, dt); + this.fireCollisions(instance, i, ctx); + // With an owner the click has to land on it; a behavior without one has no shape to + // be hit, so it listens to the whole game view (anywhere inside the camera frame). + const pointer = world.pointer; + const owner = instance.entity.parent; + if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) { + i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y }); + } + i.fire('OnUpdate', ctx, { dt }); + }, + dt, + ); } } @@ -140,14 +156,20 @@ export class BehaviorSystem implements System { } /** Runs graph code for one instance; an exception stops play and is logged, never thrown. */ - private run(instance: Instance, body: (interpreter: GraphInterpreter, ctx: NodeContext) => void, dt: number): void { + private run( + instance: Instance, + body: (interpreter: GraphInterpreter, ctx: NodeContext) => void, + dt: number, + ): void { const world = this.world; if (!world) return; const ctx = this.context(instance.entity, world, dt); try { body(instance.interpreter, ctx); } catch (error) { - world.log.push(`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`); + world.log.push( + `Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`, + ); world.stopRequested = true; } } @@ -184,6 +206,8 @@ export class BehaviorSystem implements System { }, log: (message) => { world.log.push(message); + // The `Log` node's whole purpose: the author wants this in the browser console. + // eslint-disable-next-line no-console console.log(`[behavior ${entity.name}]`, message); }, spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index 393a84e8..558d69be 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -1,6 +1,13 @@ import { isHandleEnabled, worldPositionOf } from '../../core/graph'; import { parentChain } from '../../core/model/walk'; -import { axisCorrection, colliderShape, colliderSize, isTrigger, type PlaneAxis, shapesOverlap } from '../../core/properties/collider'; +import { + axisCorrection, + colliderShape, + colliderSize, + isTrigger, + type PlaneAxis, + shapesOverlap, +} from '../../core/properties/collider'; import type { Entity } from '../Entity'; import type { World } from '../World'; import type { System } from './System'; @@ -79,22 +86,52 @@ export class CollisionSystem implements System { * are split into sub-steps so nothing jumps across a thin wall. Triggers, the entity's own * colliders and colliders that travel with it (descendants) never block. */ - moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } { + moveAndSlide( + world: World, + entity: Entity, + dx: number, + dy: number, + ): { blockedX: boolean; blockedY: boolean } { const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c)); - const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity)); - return { blockedX: this.slideAxis(entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(entity, own, obstacles, 'y', dy) }; + const obstacles = + own.length === 0 + ? [] + : world.entities.filter( + (c) => + c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity), + ); + return { + blockedX: this.slideAxis(entity, own, obstacles, 'x', dx), + blockedY: this.slideAxis(entity, own, obstacles, 'y', dy), + }; } - private slideAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean { + private slideAxis( + entity: Entity, + own: Entity[], + obstacles: Entity[], + axis: PlaneAxis, + delta: number, + ): boolean { if (delta === 0 || own.length === 0) return false; // Long steps are split so a fast mover cannot jump across a thin obstacle in one frame. - const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis)))); - for (let i = 0; i < steps; i++) if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true; + const steps = Math.min( + MAX_STEPS, + Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))), + ); + for (let i = 0; i < steps; i++) + if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true; return false; } /** One indivisible move along `axis`; returns whether an obstacle stopped it. */ - private stepAxis(entity: Entity, own: Entity[], obstacles: Entity[], axis: PlaneAxis, delta: number): boolean { + private stepAxis( + entity: Entity, + own: Entity[], + obstacles: Entity[], + axis: PlaneAxis, + delta: number, + ): boolean { const dir: 1 | -1 = delta > 0 ? 1 : -1; // How deep each pair already sits in the other before the step. A step may never deepen // that, but resolving it is not our job (no jolt, and leaving a wall is always possible). diff --git a/src/runtime/systems/Input.ts b/src/runtime/systems/Input.ts index a9d2af11..a23a253b 100644 --- a/src/runtime/systems/Input.ts +++ b/src/runtime/systems/Input.ts @@ -8,18 +8,29 @@ export class Input { private readonly released = new Set(); private readonly onKeyDown = (e: KeyboardEvent) => { if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return; - if (!this.down.has(e.key)) this.pressed.add(e.key); // ignore auto-repeat - this.down.add(e.key); + this.press(e.key); if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault(); }; - private readonly onKeyUp = (e: KeyboardEvent) => { - if (this.down.delete(e.key)) this.released.add(e.key); - }; + private readonly onKeyUp = (e: KeyboardEvent) => this.release(e.key); /** Losing the window releases every key, so nothing stays "held" behind the user's back. */ - private readonly onBlur = () => { + private readonly onBlur = () => this.releaseAll(); + + // The state machine below is what the DOM handlers above drive – and what tests drive + // directly, since a headless run has no window to dispatch key events on. + + press(key: string): void { + if (!this.down.has(key)) this.pressed.add(key); // ignore auto-repeat + this.down.add(key); + } + + release(key: string): void { + if (this.down.delete(key)) this.released.add(key); + } + + releaseAll(): void { for (const key of this.down) this.released.add(key); this.down.clear(); - }; + } attach(): void { window.addEventListener('keydown', this.onKeyDown); diff --git a/src/ui/FocusManager.ts b/src/ui/FocusManager.ts index 5ffa09ff..dac2f074 100644 --- a/src/ui/FocusManager.ts +++ b/src/ui/FocusManager.ts @@ -62,9 +62,7 @@ export class FocusManager { const list = collectFocusables(this.root); if (list.length === 0) return this.focus(null); const index = this.focusedWidget ? list.indexOf(this.focusedWidget) : -1; - const next = backwards - ? (index <= 0 ? list.length : index) - 1 - : (index + 1) % list.length; + const next = backwards ? (index <= 0 ? list.length : index) - 1 : (index + 1) % list.length; this.focus(list[next] ?? null); } diff --git a/src/ui/KeyboardBridge.ts b/src/ui/KeyboardBridge.ts index b1a44633..9bd4f2d5 100644 --- a/src/ui/KeyboardBridge.ts +++ b/src/ui/KeyboardBridge.ts @@ -66,7 +66,12 @@ export class KeyboardBridge { } /** Start editing: mirror the field's value into the input and grab DOM focus. */ - attach(target: TextEditTarget, value: string, selectionStart = value.length, selectionEnd = selectionStart): void { + attach( + target: TextEditTarget, + value: string, + selectionStart = value.length, + selectionEnd = selectionStart, + ): void { this.suppressBlur = true; this.target = target; this.input.value = value; diff --git a/src/ui/UIContext.ts b/src/ui/UIContext.ts index 8c135bbd..b5418831 100644 --- a/src/ui/UIContext.ts +++ b/src/ui/UIContext.ts @@ -1,4 +1,4 @@ -import type { Application, Container, FederatedPointerEvent } from 'pixi.js'; +import type { Application, Container } from 'pixi.js'; import { FocusManager } from './FocusManager'; import { KeyboardBridge } from './KeyboardBridge'; @@ -21,11 +21,7 @@ export function initUI(app: Application, root: Container, overlay: Container): U context = { app, focus: new FocusManager(root), keyboard: new KeyboardBridge(), overlay }; // Capture phase on the stage runs before any widget handler: a press outside the focused // widget blurs it (committing text edits) before the press can change selection. - app.stage.addEventListener( - 'pointerdown', - (e) => context?.focus.blurIfOutside((e as FederatedPointerEvent).target as Container), - { capture: true }, - ); + app.stage.addEventListener('pointerdown', (e) => context?.focus.blurIfOutside(e.target), { capture: true }); // Clicking the canvas must not steal DOM focus from the keyboard bridge input. app.canvas.addEventListener('mousedown', (e) => e.preventDefault()); app.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); diff --git a/src/ui/color.ts b/src/ui/color.ts index 7767629f..c4268305 100644 --- a/src/ui/color.ts +++ b/src/ui/color.ts @@ -46,7 +46,7 @@ export function hsvToRgb({ h, s, v }: HSV): number { const c = v * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = v - c; - let r = 0, g = 0, b = 0; + let r: number, g: number, b: number; if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0]; else if (h < 180) [r, g, b] = [0, c, x]; diff --git a/src/ui/layout/Stack.ts b/src/ui/layout/Stack.ts index 719029a1..045e086a 100644 --- a/src/ui/layout/Stack.ts +++ b/src/ui/layout/Stack.ts @@ -17,7 +17,10 @@ export abstract class Stack extends Widget { padding: number; align: NonNullable; - constructor(protected readonly axis: 'x' | 'y', options: StackOptions = {}) { + constructor( + protected readonly axis: 'x' | 'y', + options: StackOptions = {}, + ) { super(); this.gap = options.gap ?? 0; this.padding = options.padding ?? 0; diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index 3183ad1e..ccb59b16 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -34,7 +34,9 @@ export class Button extends Widget { this.addChild(this.bg, this.labelText); // Leaving while pressed cancels the click: a release elsewhere must not activate. - this.trackHover(() => { this.pressed = false; }); + this.trackHover(() => { + this.pressed = false; + }); this.on('pointerdown', (e: FederatedPointerEvent) => { if (e.button !== 0) return; this.pressed = true; @@ -89,10 +91,22 @@ export class Button extends Widget { const flat = this.variant === 'default' || this.variant === 'ghost'; let fill: number | null = c.control; let border: number | null = c.border; - if (this.variant === 'primary') { fill = c.accent; border = null; } - if (this.variant === 'danger') { fill = c.danger; border = null; } - if (this.variant === 'ghost') { fill = null; border = null; } - if (this.toggled) { fill = c.accent; border = null; } + if (this.variant === 'primary') { + fill = c.accent; + border = null; + } + if (this.variant === 'danger') { + fill = c.danger; + border = null; + } + if (this.variant === 'ghost') { + fill = null; + border = null; + } + if (this.toggled) { + fill = c.accent; + border = null; + } if (this.hovered && !this.toggled && flat) fill = c.controlHover; if (this.pressed && flat) fill = c.controlActive; const stroke = this.focused ? c.accent : border; diff --git a/src/ui/widgets/Checkbox.ts b/src/ui/widgets/Checkbox.ts index 8af20556..6dda4cd7 100644 --- a/src/ui/widgets/Checkbox.ts +++ b/src/ui/widgets/Checkbox.ts @@ -54,7 +54,11 @@ export class Checkbox extends Widget { y, }); if (this.checked) { - this.box.moveTo(4, y + 8.5).lineTo(7, y + 11.5).lineTo(12.5, y + 5).stroke({ width: 2, color: c.accentText }); + this.box + .moveTo(4, y + 8.5) + .lineTo(7, y + 11.5) + .lineTo(12.5, y + 5) + .stroke({ width: 2, color: c.accentText }); } } } diff --git a/src/ui/widgets/ColorPicker.ts b/src/ui/widgets/ColorPicker.ts index b03551e4..4e57d964 100644 --- a/src/ui/widgets/ColorPicker.ts +++ b/src/ui/widgets/ColorPicker.ts @@ -54,7 +54,9 @@ export class ColorPicker extends Popup { this.applyPointer(local.x, local.y); }); // The press starts on the body; its release bubbles up to the popup either way. - this.onPointerRelease(() => { this.dragging = null; }); + this.onPointerRelease(() => { + this.dragging = null; + }); this.content.add(this.body); this.renderSv(); } diff --git a/src/ui/widgets/Dialog.ts b/src/ui/widgets/Dialog.ts index 42677892..8baca2b5 100644 --- a/src/ui/widgets/Dialog.ts +++ b/src/ui/widgets/Dialog.ts @@ -22,7 +22,9 @@ export class Dialog extends Popup { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.add(new Spacer(), 1); // pushes the buttons to the right for (const b of buttons) { - const button = new Button(b.label, { variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default' }); + const button = new Button(b.label, { + variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default', + }); button.onClick = this.closeThen(b.onClick); row.add(button); } diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts index 71958d64..4f6bdb11 100644 --- a/src/ui/widgets/ListRow.ts +++ b/src/ui/widgets/ListRow.ts @@ -20,7 +20,11 @@ export class ListRow extends Widget { private selected = false; private listFocused = false; - constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) { + constructor( + readonly item: ListItem, + height: number, + private readonly chevronSlot: boolean, + ) { super(); this.cursor = 'pointer'; this.minHeight = height; @@ -59,12 +63,17 @@ export class ListRow extends Widget { const depth = this.item.depth ?? 0; const indent = this.indent; this.bg.clear(); - if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive); + if (this.selected) + this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive); else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover); // Tree guide: a short "└" mark in front of nested rows. if (depth > 0) { const gx = indent - 8; - this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border }); + this.bg + .moveTo(gx, 0) + .lineTo(gx, this.h / 2) + .lineTo(indent - 2, this.h / 2) + .stroke({ width: 1, color: c.border }); } let x = indent; if (this.chevronSlot) { @@ -86,13 +95,26 @@ export class ListRow extends Widget { const hintW = this.hint.text ? this.hint.width + Theme.padding : 0; this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding); this.labelText.position.set(x, centreY(this.h, this.labelText.height)); - this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), centreY(this.h, this.hint.height)); + this.hint.position.set( + Math.round(this.w - Theme.padding - this.hint.width), + centreY(this.h, this.hint.height), + ); } /** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */ private drawChevron(x: number, y: number, expanded: boolean): void { const color = this.selected ? Theme.colors.text : Theme.colors.textMuted; - if (expanded) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color); - else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color); + if (expanded) + this.bg + .moveTo(x - 3, y - 2) + .lineTo(x + 5, y - 2) + .lineTo(x + 1, y + 3) + .fill(color); + else + this.bg + .moveTo(x - 1, y - 4) + .lineTo(x + 4, y) + .lineTo(x - 1, y + 4) + .fill(color); } } diff --git a/src/ui/widgets/NumberField.ts b/src/ui/widgets/NumberField.ts index a7126d33..6b83248f 100644 --- a/src/ui/widgets/NumberField.ts +++ b/src/ui/widgets/NumberField.ts @@ -124,6 +124,8 @@ export class NumberField extends TextField { /** Clamps to min/max; NaN/Infinity keep the current value (0 before the first assignment). */ private clampValue(value: number): number { + // `?? 0` is not dead despite the type: the constructor calls this before assigning `numeric`. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!Number.isFinite(value)) return this.numeric ?? 0; return clamp(value, this.min, this.max); } diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index fec4f662..df1a5c3f 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -39,7 +39,9 @@ export class ScrollContainer extends Widget { this.scrollTo(this.dragScrollStart + (e.global.y - this.dragStart) * ratio); }); // The press starts on the scrollbar graphics; its release bubbles up to us either way. - this.onPointerRelease(() => { this.draggingBar = false; }); + this.onPointerRelease(() => { + this.draggingBar = false; + }); } /** Replaces the scrolled widget. The previous content is detached, not destroyed. */ @@ -99,6 +101,8 @@ export class ScrollContainer extends Widget { const x = this.w - trackW - 2; const barH = Math.max(20, (this.h / this.contentHeight) * this.h); const barY = (this.scrollY / this.maxScroll) * (this.h - barH); - this.scrollbar.roundRect(x, barY, trackW, barH, trackW / 2).fill({ color: Theme.colors.textMuted, alpha: 0.6 }); + this.scrollbar + .roundRect(x, barY, trackW, barH, trackW / 2) + .fill({ color: Theme.colors.textMuted, alpha: 0.6 }); } } diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts index ac251f36..beb0c574 100644 --- a/src/ui/widgets/TextField.ts +++ b/src/ui/widgets/TextField.ts @@ -61,7 +61,14 @@ export class TextField extends Widget implements TextEditTarget { this.minWidth = options.width ?? 80; this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') }); this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') }); - this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, this.clipShape); + this.addChild( + this.bg, + this.selectionShape, + this.placeholderNode, + this.textNode, + this.caret, + this.clipShape, + ); this.mask = this.clipShape; this.trackHover(); @@ -71,7 +78,9 @@ export class TextField extends Widget implements TextEditTarget { const index = this.indexAt(this.toLocal(e.global).x); this.setSelection(this.selStart, index, true); }); - this.onPointerRelease(() => { this.dragSelecting = false; }); + this.onPointerRelease(() => { + this.dragSelecting = false; + }); } /** Current text, including an edit in progress that has not been committed yet. */ @@ -216,7 +225,10 @@ export class TextField extends Widget implements TextEditTarget { let bestDist = Infinity; for (let i = 0; i <= this.valueText.length; i++) { const dist = Math.abs(this.textWidth(this.valueText.slice(0, i)) - x); - if (dist < bestDist) { bestDist = dist; best = i; } + if (dist < bestDist) { + bestDist = dist; + best = i; + } } return best; } @@ -248,7 +260,10 @@ export class TextField extends Widget implements TextEditTarget { fill: this.hovered && !this.editing ? c.controlHover : c.control, border: this.focused ? c.accent : c.border, }); - this.clipShape.clear().rect(1, 1, this.w - 2, this.h - 2).fill(0xffffff); + this.clipShape + .clear() + .rect(1, 1, this.w - 2, this.h - 2) + .fill(0xffffff); this.textNode.text = this.valueText; this.placeholderNode.text = this.valueText ? '' : this.placeholder; @@ -271,7 +286,9 @@ export class TextField extends Widget implements TextEditTarget { if (hi > lo) { const x1 = textX + this.textWidth(this.valueText.slice(0, lo)); const x2 = textX + this.textWidth(this.valueText.slice(0, hi)); - this.selectionShape.rect(x1, textY, x2 - x1, this.textNode.height).fill({ color: c.accent, alpha: 0.45 }); + this.selectionShape + .rect(x1, textY, x2 - x1, this.textNode.height) + .fill({ color: c.accent, alpha: 0.45 }); } if (this.caretVisible) { this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text); diff --git a/src/viewport/SceneViewport.ts b/src/viewport/SceneViewport.ts index e0f74adb..c8929a0d 100644 --- a/src/viewport/SceneViewport.ts +++ b/src/viewport/SceneViewport.ts @@ -116,7 +116,11 @@ export class SceneViewport extends WorldViewport { if (e.button === 0 && e.target === this) { // Empty space: a click deselects, a drag becomes a rubber band (decided on release). const local = this.toLocal(e.global); - this.marquee = { start: { x: local.x, y: local.y }, current: { x: local.x, y: local.y }, active: false }; + this.marquee = { + start: { x: local.x, y: local.y }, + current: { x: local.x, y: local.y }, + active: false, + }; e.stopPropagation(); } } @@ -137,13 +141,22 @@ export class SceneViewport extends WorldViewport { // A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy). for (const item of this.dragging.items) { const object = findObject(scene, item.objectId); - if (object) object.position = { x: Math.round(item.start.x + dx), y: Math.round(item.start.y + dy), z: item.start.z }; + if (object) + object.position = { + x: Math.round(item.start.x + dx), + y: Math.round(item.start.y + dy), + z: item.start.z, + }; } this.state.preview(); } else if (this.marquee) { const local = this.toLocal(e.global); this.marquee.current = { x: local.x, y: local.y }; - if (!this.marquee.active && Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD) return; + if ( + !this.marquee.active && + Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD + ) + return; this.marquee.active = true; this.redraw(); } @@ -198,7 +211,8 @@ export class SceneViewport extends WorldViewport { return; } // Clicking a selected object keeps the selection so the whole group can be dragged. - if (!this.state.selectedObjectIds.includes(objectId)) this.state.select(objectSelection(scene.id, [objectId])); + if (!this.state.selectedObjectIds.includes(objectId)) + this.state.select(objectSelection(scene.id, [objectId])); // Only the tops of the selected subtrees move; their descendants follow through the hierarchy. const items = subtreeRoots(scene, this.state.selectedObjectIds) .map((id) => findObject(scene, id)) @@ -219,7 +233,9 @@ export class SceneViewport extends WorldViewport { const maxX = Math.max(a.x, b.x); const minY = Math.min(a.y, b.y); const maxY = Math.max(a.y, b.y); - const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id); + const ids = this.lastItems + .filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY) + .map((i) => i.id); this.state.select(objectSelection(scene.id, ids)); } @@ -249,7 +265,15 @@ export class SceneViewport extends WorldViewport { const step = GRID_STEP * this.view.zoom; if (step < MIN_GRID_STEP) return; const origin = worldToScreen(this.currentView(), 0, 0); - drawGrid(this.grid, { originX: origin.x, originY: origin.y, step, w: this.w, h: this.h, alpha: 0.05, axisAlpha: 0.15 }); + drawGrid(this.grid, { + originX: origin.x, + originY: origin.y, + step, + w: this.w, + h: this.h, + alpha: 0.05, + axisAlpha: 0.15, + }); } /** @@ -287,7 +311,8 @@ export class SceneViewport extends WorldViewport { label.visible = true; label.text = item.name; label.scale.set(1 / scale); - if (item.type === 'camera' || item.type === 'collider') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale); + if (item.type === 'camera' || item.type === 'collider') + label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale); else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale); } @@ -305,7 +330,12 @@ export class SceneViewport extends WorldViewport { const sh = h * view.scale; this.gizmos.rect(tl.x - 2, tl.y - 2, sw + 4, sh + 4).stroke({ width: 2, color: Theme.colors.gizmo }); if (id !== s.objectId) continue; - for (const [cx, cy] of [[tl.x, tl.y], [tl.x + sw, tl.y], [tl.x, tl.y + sh], [tl.x + sw, tl.y + sh]] as const) { + for (const [cx, cy] of [ + [tl.x, tl.y], + [tl.x + sw, tl.y], + [tl.x, tl.y + sh], + [tl.x + sw, tl.y + sh], + ] as const) { this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo); } } @@ -316,7 +346,10 @@ export class SceneViewport extends WorldViewport { const y = Math.min(m.start.y, m.current.y); const w = Math.abs(m.current.x - m.start.x); const h = Math.abs(m.current.y - m.start.y); - this.gizmos.rect(x, y, w, h).fill({ color: Theme.colors.marquee, alpha: 0.12 }).stroke({ width: 1, color: Theme.colors.marquee }); + this.gizmos + .rect(x, y, w, h) + .fill({ color: Theme.colors.marquee, alpha: 0.12 }) + .stroke({ width: 1, color: Theme.colors.marquee }); } } } diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index f186cfe4..2ee83f80 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -55,7 +55,9 @@ export abstract class WorldViewport extends Widget { state.on('selection', () => this.redraw()); state.on('mode', () => this.redraw()); // Every tick moves things: redraw the live view when it is on screen. - state.on('runtime', () => { if (this.visible) this.redraw(); }); + state.on('runtime', () => { + if (this.visible) this.redraw(); + }); } override measure(): Size { @@ -143,7 +145,10 @@ export abstract class WorldViewport extends Widget { if (this.paintsBackground()) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background); this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff); - this.world.position.set(rect.x + rect.w / 2 - view.x * view.scale, rect.y + rect.h / 2 - view.y * view.scale); + this.world.position.set( + rect.x + rect.w / 2 - view.x * view.scale, + rect.y + rect.h / 2 - view.y * view.scale, + ); this.world.scale.set(view.scale); const bounds = worldBounds(view); @@ -214,7 +219,10 @@ export abstract class WorldViewport extends Widget { return; } if (!text) { - text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) }); + text = new Text({ + text: '', + style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }), + }); text.eventMode = 'none'; this.texts.set(item.id, text); this.objects.addChild(text); @@ -227,7 +235,7 @@ export abstract class WorldViewport extends Widget { const color = Number(item.props.color ?? 0xffffff); if (text.style.fill !== color) text.style.fill = color; text.anchor.set(textAnchor(item.props), 0.5); - const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2)); + const resolution = Math.min(4, Math.max(1, (Math.round(scale * 2) / 2) * 2)); if (text.resolution !== resolution) text.resolution = resolution; } } diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 8cafdbd6..e2fc7c08 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -52,7 +52,12 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number }); } const byId = new Map(items.map((i) => [i.id, i])); - for (const item of items) item.enabled = enabledAlongChain(item, (i) => i.props, (i) => byId.get(i.parentId) ?? null); + for (const item of items) + item.enabled = enabledAlongChain( + item, + (i) => i.props, + (i) => byId.get(i.parentId) ?? null, + ); return { items, background: Number(scene.props.backgroundColor ?? fallbackBackground) }; } @@ -60,6 +65,14 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number export function collectWorldItems(world: World): DrawList { const items = world.entities .filter((e) => !isSymbolType(e.type) && isHandleEnabled(e)) - .map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...worldPositionOf(e), props: e.props, parentId: e.parent?.objectId ?? '', enabled: true })); + .map((e) => ({ + id: e.objectId, + type: e.type, + name: e.name, + ...worldPositionOf(e), + props: e.props, + parentId: e.parent?.objectId ?? '', + enabled: true, + })); return { items, background: world.backgroundColor }; } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index a47aae71..2fa38799 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -3,7 +3,7 @@ * separate Pixi Text), and the editor symbols for cameras, behaviors, containers and * colliders. Everything here draws in world units at the item's origin. */ -import { Graphics, Rectangle } from 'pixi.js'; +import { type Graphics, Rectangle } from 'pixi.js'; import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds'; import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; import { Theme } from '../ui'; @@ -16,7 +16,12 @@ const COLLIDER_GRAB_BAND = 6; * Draws `item` into its node. `scale` is the current view scale (pixels per world unit), so * strokes and hit bands can stay constant on screen. */ -export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: number }, scale: number): void { +export function drawItem( + node: Graphics, + item: DrawItem, + size: { w: number; h: number }, + scale: number, +): void { node.clear(); node.hitArea = null; switch (item.type) { @@ -24,7 +29,9 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff)); return; case 'camera': - node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / scale, color: Theme.colors.textMuted }); + node + .rect(-size.w / 2, -size.h / 2, size.w, size.h) + .stroke({ width: 2 / scale, color: Theme.colors.textMuted }); node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted); node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted); return; @@ -32,7 +39,12 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n // A diamond with a small "circuit" mark: the logic symbol. const r = BEHAVIOR_SYMBOL / 2; node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior); - node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText }); + node + .moveTo(-6, -3) + .lineTo(-1, -3) + .lineTo(-1, 3) + .lineTo(6, 3) + .stroke({ width: 2, color: Theme.colors.accentText }); node.circle(-6, -3, 2).fill(Theme.colors.accentText); node.circle(6, 3, 2).fill(Theme.colors.accentText); return; @@ -41,12 +53,26 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n // A square with four small boxes inside: the group symbol. Its content is drawn as // ordinary objects (they are ordinary objects, parented below the container). const r = CONTAINER_SYMBOL / 2; - node.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3).fill({ color: Theme.colors.container, alpha: 0.25 }).stroke({ width: 2, color: Theme.colors.container }); - for (const [bx, by] of [[-7, -7], [1, -7], [-7, 1], [1, 1]] as const) node.rect(bx, by, 6, 6).fill(Theme.colors.container); + node + .roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3) + .fill({ color: Theme.colors.container, alpha: 0.25 }) + .stroke({ width: 2, color: Theme.colors.container }); + for (const [bx, by] of [ + [-7, -7], + [1, -7], + [-7, 1], + [1, 1], + ] as const) + node.rect(bx, by, 6, 6).fill(Theme.colors.container); return; } case 'text': - node.hitArea = new Rectangle(-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, -size.h / 2, size.w, size.h); + node.hitArea = new Rectangle( + -size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, + -size.h / 2, + size.w, + size.h, + ); return; case 'collider': { // Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on @@ -66,7 +92,10 @@ export function drawItem(node: Graphics, item: DrawItem, size: { w: number; h: n } /** Hit area covering only the outline band of a collider shape (local coordinates, world units). */ -function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(x: number, y: number): boolean } { +function colliderOutlineHitArea( + shape: ColliderShape, + band: number, +): { contains(x: number, y: number): boolean } { if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band }; const hw = shape.w / 2; const hh = shape.h / 2; @@ -80,7 +109,15 @@ function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains( } /** Adds a dashed line path to `g` (Pixi has no dash style); the caller strokes it. */ -export function dashedLine(g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number): void { +export function dashedLine( + g: Graphics, + x1: number, + y1: number, + x2: number, + y2: number, + dash: number, + gap: number, +): void { const length = Math.hypot(x2 - x1, y2 - y1); if (length === 0) return; const ux = (x2 - x1) / length; diff --git a/tests/core/apply.test.ts b/tests/core/apply.test.ts index 7d537546..ed3d3945 100644 --- a/tests/core/apply.test.ts +++ b/tests/core/apply.test.ts @@ -26,7 +26,9 @@ function setup() { const instance = place(f, create.assetId, { x: 1000, y: 0 }); const container = () => findAsset(f.project, create.assetId) as ContainerAsset; const templateOfA = () => container().objects.find((t) => t.id === f.object(a).templateId)!; - const a2 = f.sceneOf().objects.find((o) => o.parentId === instance && o.templateId === f.object(a).templateId)!.id; + const a2 = f + .sceneOf() + .objects.find((o) => o.parentId === instance && o.templateId === f.object(a).templateId)!.id; return { ...f, a, b, a2, container, templateOfA }; } @@ -80,7 +82,9 @@ describe('position apply / revert', () => { it('rejects objects that are not part of a container', () => { const f = setup(); const loose = place(f, f.spriteId, { x: 0, y: 0 }); - expect(() => f.stack.execute(new ApplyPositionToContainerCommand(f.sceneId, loose))).toThrow(/not part of a placed container/); + expect(() => f.stack.execute(new ApplyPositionToContainerCommand(f.sceneId, loose))).toThrow( + /not part of a placed container/, + ); expect(f.sceneOf().objects.find((o) => o.id === loose)).toBeDefined(); }); }); @@ -133,7 +137,9 @@ describe('override apply / revert', () => { stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'height')); expect(findAsset(project, spriteId)!.props.height).toBe(33); expect(object(loose).overrides.height).toBeUndefined(); - expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'nope'))).toThrow(/not defined/); + expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'nope'))).toThrow( + /not defined/, + ); }); it('reverts an override to the template value (or removes it when the template has none)', () => { diff --git a/tests/core/behavior-events.test.ts b/tests/core/behavior-events.test.ts new file mode 100644 index 00000000..c8d3966a --- /dev/null +++ b/tests/core/behavior-events.test.ts @@ -0,0 +1,214 @@ +/** + * The event nodes have no `run` – the BehaviorSystem decides when they fire. These tests drive a + * real world and check that timing: key edges vs. held keys, the timer's own clock, and the + * difference between a collision that starts and one that continues. + */ +import { describe, expect, it } from 'vitest'; +import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; +import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers'; + +/** A scene with a 100×60 sprite and a behavior placed below it, ready to have an event wired up. */ +function scene(): SceneFixture & { behavior: BehaviorAsset; box: string } { + const f = makeSpriteScene('Box'); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60)); + const behavior = makeBehavior(f, 'Logic'); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + place(f, behavior.id, { x: 0, y: 0 }, box); + return { ...f, behavior, box }; +} + +/** Wires ` → Log(message)` so a fired event shows up as one entry in `world.log`. */ +function logOn( + f: SceneFixture & { behavior: BehaviorAsset }, + event: string, + params: Record = {}, + message = event, +): void { + const edit = graphEditor(f, f.behavior); + const node = edit.addNode(event, params); + const log = edit.addNode('Log', { message }); + edit.connect(node, 'exec', log, 'exec'); +} + +describe('key events', () => { + it('On key down fires once per press, not while the key stays held', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'a' }, 'down'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + frame(); + expect(world.log).toEqual([]); + + input.press('a'); + frame(); + expect(world.log).toEqual(['down']); + + input.press('a'); // auto-repeat while held: no new edge + frame(); + frame(); + expect(world.log).toEqual(['down']); + + input.release('a'); + input.press('a'); + frame(); + expect(world.log).toEqual(['down', 'down']); + }); + + it('On key up fires once when the key is released', () => { + const f = scene(); + logOn(f, 'OnKeyUp', { key: 'a' }, 'up'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a'); + frame(); + frame(); + expect(world.log).toEqual([]); + + input.release('a'); + frame(); + expect(world.log).toEqual(['up']); + + frame(); // the edge is consumed, not sticky + expect(world.log).toEqual(['up']); + }); + + it('On key held fires every frame the key is down', () => { + const f = scene(); + logOn(f, 'OnKeyHeld', { key: 'a' }, 'held'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a'); + frame(); + frame(); + frame(); + expect(world.log).toEqual(['held', 'held', 'held']); + + input.release('a'); + frame(); + expect(world.log).toHaveLength(3); + }); + + it('only the node whose key matches fires', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'a' }, 'a'); + logOn(f, 'OnKeyDown', { key: 'b' }, 'b'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('b'); + frame(); + expect(world.log).toEqual(['b']); + }); + + it('losing the window releases every held key, so nothing stays down behind the user’s back', () => { + const f = scene(); + logOn(f, 'OnKeyUp', { key: 'a' }, 'up'); + logOn(f, 'OnKeyHeld', { key: 'a' }, 'held'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a'); + frame(); + expect(world.log).toEqual(['held']); + + input.releaseAll(); + frame(); + expect(world.log).toEqual(['held', 'up']); + frame(); + expect(world.log).toHaveLength(2); + }); +}); + +describe('On timer', () => { + it('fires on its own clock, independent of the frame rate', () => { + const f = scene(); + logOn(f, 'OnTimer', { interval: 1 }, 'tick'); + const { world, frame } = startWorld(f, { behaviors: true }); + + for (let i = 0; i < 4; i++) frame(0.2); // 0.8 s + expect(world.log).toEqual([]); + frame(0.2); // 1.0 s + expect(world.log).toEqual(['tick']); + for (let i = 0; i < 5; i++) frame(0.2); // 2.0 s + expect(world.log).toEqual(['tick', 'tick']); + }); + + it('catches up when one frame is longer than the interval', () => { + const f = scene(); + logOn(f, 'OnTimer', { interval: 0.5 }, 'tick'); + const { world, frame } = startWorld(f, { behaviors: true }); + + frame(2); // a hitch worth four intervals + expect(world.log).toEqual(['tick', 'tick', 'tick', 'tick']); + }); + + it('two timers keep separate clocks', () => { + const f = scene(); + logOn(f, 'OnTimer', { interval: 1 }, 'slow'); + logOn(f, 'OnTimer', { interval: 0.5 }, 'fast'); + const { world, frame } = startWorld(f, { behaviors: true }); + + frame(0.5); + expect(world.log).toEqual(['fast']); + frame(0.5); + expect(world.log).toEqual(['fast', 'slow', 'fast']); + }); +}); + +describe('On collision stay', () => { + it('fires every frame the overlap lasts, while enter fires only on the first one', () => { + const f = makeSpriteScene('Crate'); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60)); + const behavior = makeBehavior(f, 'Hit'); + const a = place(f, f.spriteId, { x: 0, y: 0 }); + const b = place(f, f.spriteId, { x: 300, y: 0 }); + f.stack.execute(new AddColliderCommand(f.sceneId, a)); + f.stack.execute(new AddColliderCommand(f.sceneId, b)); + place(f, behavior.id, { x: 0, y: 0 }, a); + const fixture = { ...f, behavior }; + logOn(fixture, 'OnCollisionEnter', {}, 'enter'); + logOn(fixture, 'OnCollisionStay', {}, 'stay'); + logOn(fixture, 'OnCollisionExit', {}, 'exit'); + + const { world, frame } = startWorld(f, { collisions: true, behaviors: true }); + const crateB = world.findByObjectId(b)!; + + frame(0.1); + expect(world.log).toEqual([]); + + crateB.x = 50; // now overlapping + frame(0.1); + expect(world.log).toEqual(['enter']); + frame(0.1); + frame(0.1); + expect(world.log).toEqual(['enter', 'stay', 'stay']); + + crateB.x = 500; + frame(0.1); + expect(world.log).toEqual(['enter', 'stay', 'stay', 'exit']); + frame(0.1); + expect(world.log).toHaveLength(4); + }); +}); + +describe('a switched-off behavior receives no events', () => { + it('ignores keys and timers until it is switched back on', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'a' }, 'down'); + logOn(f, 'OnTimer', { interval: 0.5 }, 'tick'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + const logic = world.entities.find((e) => e.name === 'Logic')!; + + logic.props.enabled = false; + input.press('a'); + frame(1); + expect(world.log).toEqual([]); + + logic.props.enabled = true; + input.release('a'); + input.press('a'); + frame(1); + // The timer starts counting when the behavior runs again – no backlog from the off period. + expect(world.log).toEqual(['down', 'tick', 'tick']); + }); +}); diff --git a/tests/core/collider.test.ts b/tests/core/collider.test.ts index 8e8ed278..deaaacef 100644 --- a/tests/core/collider.test.ts +++ b/tests/core/collider.test.ts @@ -3,7 +3,12 @@ import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { findAsset } from '../../src/core/model/Project'; -import { axisCorrection, axisSeparation, colliderShape, shapesOverlap } from '../../src/core/properties/collider'; +import { + axisCorrection, + axisSeparation, + colliderShape, + shapesOverlap, +} from '../../src/core/properties/collider'; import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h }); @@ -92,8 +97,15 @@ describe('AddColliderCommand', () => { it('refuses a collider on a collider and survives a round trip', () => { const { project, stack, sceneId, colA } = setup(); - expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(/cannot have a collider/); - expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({ shape: 'box', width: 100, height: 60, radius: 50 }); + expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow( + /cannot have a collider/, + ); + expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({ + shape: 'box', + width: 100, + height: 60, + radius: 50, + }); }); }); @@ -130,7 +142,15 @@ describe('CollisionSystem', () => { world.findByObjectId(b)!.x = 75; // circle r=30 at 75 vs box half-width 50 → overlap world.tick(0.1); expect(collisions.enters).toHaveLength(2); // circle B against both colliders of A - expect(collisions.enters.every((p) => p.a.objectId === colA.objectId || p.b.objectId === colA.objectId || p.a.objectId === second.objectId || p.b.objectId === second.objectId)).toBe(true); + expect( + collisions.enters.every( + (p) => + p.a.objectId === colA.objectId || + p.b.objectId === colA.objectId || + p.a.objectId === second.objectId || + p.b.objectId === second.objectId, + ), + ).toBe(true); }); }); @@ -231,7 +251,7 @@ describe('move and slide', () => { }); describe('collision events in behaviors', () => { - it('fires On collision enter/exit on the body\'s behavior with the other body; Is touching agrees', () => { + it("fires On collision enter/exit on the body's behavior with the other body; Is touching agrees", () => { const s = setup(); const { behavior, b, object } = s; // On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B")) diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts index cfadffff..1e6b9ebb 100644 --- a/tests/core/commands.test.ts +++ b/tests/core/commands.test.ts @@ -22,7 +22,12 @@ describe('asset commands', () => { const { project, sceneId, spriteId } = makeSpriteScene(); expect(project.assets).toHaveLength(2); expect(project.activeSceneId).toBe(sceneId); - expect(findAsset(project, spriteId)?.props).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f }); + expect(findAsset(project, spriteId)?.props).toEqual({ + enabled: true, + width: 100, + height: 100, + color: 0xe0553f, + }); }); it('renames and sets properties with undo/redo', () => { @@ -100,7 +105,9 @@ describe('scene object commands', () => { expect(object.position).toEqual({ x: 5, y: 6, z: 0 }); expect(object.overrides).toEqual({}); - expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(/cannot be placed/); + expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow( + /cannot be placed/, + ); expect(sceneOf().objects).toHaveLength(1); }); diff --git a/tests/core/container.test.ts b/tests/core/container.test.ts index 7cd5f3cf..0b58b727 100644 --- a/tests/core/container.test.ts +++ b/tests/core/container.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'; import { DeleteAssetCommand } from '../../src/core/commands/assetCommands'; import { CompoundCommand } from '../../src/core/commands/CompoundCommand'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; -import { MoveSceneObjectCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { + MoveSceneObjectCommand, + RemoveSceneObjectsCommand, + SetParentCommand, +} from '../../src/core/commands/sceneObjectCommands'; import { worldPositionOf } from '../../src/core/graph'; import type { ContainerAsset } from '../../src/core/model/Asset'; import { subtreeRoots, treeOrder } from '../../src/core/model/hierarchy'; diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts index a9d5ae70..5962d11d 100644 --- a/tests/core/enabled.test.ts +++ b/tests/core/enabled.test.ts @@ -6,7 +6,17 @@ import { isHandleEnabled } from '../../src/core/graph'; import { findAsset, findScene } from '../../src/core/model/Project'; import { schemaFor } from '../../src/core/properties/PropertySchema'; import { resolveSceneObjectProps } from '../../src/core/properties/resolve'; -import { fakeHandle, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip, runNode, startWorld } from '../helpers'; +import { + fakeHandle, + graphEditor, + makeBehavior, + makeProject, + makeSpriteScene, + place, + roundTrip, + runNode, + startWorld, +} from '../helpers'; /** A 100×100 wall at x = 200 and a 100×100 mover at the origin, both with box colliders, in a running world. */ function wallAndMover() { @@ -17,7 +27,12 @@ function wallAndMover() { f.stack.execute(new AddColliderCommand(f.sceneId, moverId)); f.stack.execute(wallCollider); const { world, collisions } = startWorld(f, { collisions: true }); - return { world, collisions, mover: world.findByObjectId(moverId)!, wall: world.findByObjectId(wallCollider.objectId)! }; + return { + world, + collisions, + mover: world.findByObjectId(moverId)!, + wall: world.findByObjectId(wallCollider.objectId)!, + }; } describe('the enabled switch', () => { diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index aab7dc08..f1d2072e 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -1,12 +1,30 @@ import { describe, expect, it } from 'vitest'; import { CommandError } from '../../src/core/commands/Command'; -import { AddNodeCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; -import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; +import { + AddNodeCommand, + DisconnectCommand, + RemoveNodeCommand, + SetNodeParamCommand, +} from '../../src/core/commands/graphCommands'; +import { + AddVariableCommand, + RemoveVariableCommand, + RenameVariableCommand, +} from '../../src/core/commands/variableCommands'; import { GraphInterpreter, allNodeDefs, findNode, portsCompatible } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { findAsset } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { type ProjectFixture, fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip } from '../helpers'; +import { + type ProjectFixture, + fakeContext, + graphEditor, + makeBehavior, + makeProject, + makeSpriteScene, + place, + roundTrip, +} from '../helpers'; /** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */ function withMover(fixture: F) { @@ -75,7 +93,10 @@ describe('GraphInterpreter', () => { const start = edit.addNode('OnStart'); const self = edit.addNode('Self'); const move = edit.addNode('Move', { dx: 7, dy: -3 }); - edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object']]); + edit.wire([ + [start, 'exec', move, 'exec'], + [self, 'object', move, 'object'], + ]); const ctx = fakeContext(); new GraphInterpreter(behavior.graph).fire('OnStart', ctx); expect(ctx.self.x).toBe(7); @@ -88,7 +109,11 @@ describe('GraphInterpreter', () => { const self = edit.addNode('Self'); const clamp = edit.addNode('Clamp', { value: 5 }); const move = edit.addNode('Move', { dx: 4 }); - edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object'], [clamp, 'result', move, 'dy']]); + edit.wire([ + [start, 'exec', move, 'exec'], + [self, 'object', move, 'object'], + [clamp, 'result', move, 'dy'], + ]); const ctx = fakeContext(); new GraphInterpreter(behavior.graph).fire('OnStart', ctx); expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0 @@ -145,7 +170,10 @@ describe('GraphInterpreter', () => { let previous = start; sources.forEach((source, i) => { const log = edit.addNode('Log'); - edit.wire([[previous, 'exec', log, 'exec'], [source, ports[i]!, log, 'message']]); + edit.wire([ + [previous, 'exec', log, 'exec'], + [source, ports[i]!, log, 'message'], + ]); previous = log; }); const ctx = fakeContext(); @@ -198,14 +226,21 @@ describe('GraphInterpreter', () => { const start = edit.addNode('OnStart'); const a = edit.addNode('Sequence'); const b = edit.addNode('Sequence'); - edit.wire([[start, 'exec', a, 'exec'], [a, 'a', b, 'exec'], [b, 'a', a, 'exec']]); + edit.wire([ + [start, 'exec', a, 'exec'], + [a, 'a', b, 'exec'], + [b, 'a', a, 'exec'], + ]); expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/); }); }); describe('graph commands', () => { it('rejects incompatible, self and duplicate connections', () => { - const { edit, nodes: { held, mul, move } } = withMover(makeProject()); + const { + edit, + nodes: { held, mul, move }, + } = withMover(makeProject()); expect(() => edit.connect(held, 'exec', mul, 'a')).toThrow(/Cannot connect/); expect(() => edit.connect(mul, 'result', mul, 'a')).toThrow(/itself/); expect(() => edit.connect(held, 'exec', move, 'exec')).toThrow(/Already/); @@ -218,7 +253,8 @@ describe('graph commands', () => { const before = behavior.graph.edges.length; edit.connect(literal, 'value', nodes.move, 'dx'); expect(behavior.graph.edges.length).toBe(before); // one replaced, one added - const sourceOfDx = () => behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node; + const sourceOfDx = () => + behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node; expect(sourceOfDx()).toBe(literal.id); stack.undo(); expect(sourceOfDx()).toBe(nodes.mul.id); @@ -288,10 +324,16 @@ describe('behavior serialization', () => { expect(loaded.graph).toEqual(behavior.graph); expect(loaded.props).toEqual({ enabled: true, speed: 0 }); - const raw = serializeProject(project) as { assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[] }; + const raw = serializeProject(project) as { + assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[]; + }; const rawGraph = raw.assets.find((a) => a.id === behavior.id)!.graph!; rawGraph.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} }); - rawGraph.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } }); + rawGraph.edges.push({ + id: 'e-bad', + from: { node: 'zzz', port: 'exec' }, + to: { node: behavior.graph.nodes[0]!.id, port: 'exec' }, + }); const cleaned = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset; expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length); expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length); diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index 89a5f6cf..cee1ce5b 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -1,11 +1,30 @@ import { describe, expect, it } from 'vitest'; import { DeleteAssetCommand } from '../../src/core/commands/assetCommands'; -import { MoveSceneObjectCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { + MoveSceneObjectCommand, + RemoveSceneObjectCommand, + SetParentCommand, +} from '../../src/core/commands/sceneObjectCommands'; import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph'; -import { ancestorIdsOf, descendantsOf, findObject, localPositionFor, treeOrder, visibleTreeOrder } from '../../src/core/model/hierarchy'; +import { + ancestorIdsOf, + descendantsOf, + findObject, + localPositionFor, + treeOrder, + visibleTreeOrder, +} from '../../src/core/model/hierarchy'; import { findScene } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { fakeContext, fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; +import { + fakeContext, + fakeEntity, + makeBehavior, + makeSpriteScene, + place, + roundTrip, + startWorld, +} from '../helpers'; /** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */ function threeBoxes() { @@ -58,7 +77,11 @@ describe('scene hierarchy', () => { const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); // Storage order is a, b, c; parent c under a → c is shown right after a, before b. stack.execute(new SetParentCommand(sceneId, c, a)); - expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([[a, 0], [c, 1], [b, 0]]); + expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([ + [a, 0], + [c, 1], + [b, 0], + ]); stack.execute(new SetParentCommand(sceneId, b, c)); expect(descendantsOf(sceneOf(), a).map((o) => o.id)).toEqual([c, b]); }); @@ -68,12 +91,20 @@ describe('scene hierarchy', () => { // a > c > b: two levels, so folding a hides both descendants. stack.execute(new SetParentCommand(sceneId, c, a)); stack.execute(new SetParentCommand(sceneId, b, c)); - const rows = (expanded: string[]) => visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]); + const rows = (expanded: string[]) => + visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]); // Nothing expanded (the default when a scene is opened) – only the top level is listed. expect(rows([])).toEqual([[a, 0, true]]); - expect(rows([a])).toEqual([[a, 0, true], [c, 1, true]]); - expect(rows([a, c])).toEqual([[a, 0, true], [c, 1, true], [b, 2, false]]); + expect(rows([a])).toEqual([ + [a, 0, true], + [c, 1, true], + ]); + expect(rows([a, c])).toEqual([ + [a, 0, true], + [c, 1, true], + [b, 2, false], + ]); // Expanding a nested object without its parent shows nothing extra: every ancestor must be open. expect(rows([c])).toEqual([[a, 0, true]]); }); @@ -165,26 +196,29 @@ describe('runtime hierarchy', () => { const parent = { ...fakeEntity('p', 100), y: 20 }; const child = { ...fakeEntity('c', 5), y: 5, parent }; // On start → Log(world x of self) → Log(local x of self) → Log(world x of self's parent) - const graph: Graph = { nodes: [ - { id: 'start', type: 'OnStart', x: 0, y: 0, params: {} }, - { id: 'self', type: 'Self', x: 0, y: 0, params: {} }, - { id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} }, - { id: 'par', type: 'Parent', x: 0, y: 0, params: {} }, - { id: 'ppos', type: 'GetPosition', x: 0, y: 0, params: {} }, - { id: 'log1', type: 'Log', x: 0, y: 0, params: {} }, - { id: 'log2', type: 'Log', x: 0, y: 0, params: {} }, - { id: 'log3', type: 'Log', x: 0, y: 0, params: {} }, - ], edges: [ - { id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'log1', port: 'exec' } }, - { id: 'e2', from: { node: 'log1', port: 'exec' }, to: { node: 'log2', port: 'exec' } }, - { id: 'e3', from: { node: 'log2', port: 'exec' }, to: { node: 'log3', port: 'exec' } }, - { id: 'e4', from: { node: 'self', port: 'object' }, to: { node: 'pos', port: 'object' } }, - { id: 'e5', from: { node: 'self', port: 'object' }, to: { node: 'par', port: 'object' } }, - { id: 'e6', from: { node: 'par', port: 'parent' }, to: { node: 'ppos', port: 'object' } }, - { id: 'e7', from: { node: 'pos', port: 'worldX' }, to: { node: 'log1', port: 'message' } }, - { id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } }, - { id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } }, - ] }; + const graph: Graph = { + nodes: [ + { id: 'start', type: 'OnStart', x: 0, y: 0, params: {} }, + { id: 'self', type: 'Self', x: 0, y: 0, params: {} }, + { id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} }, + { id: 'par', type: 'Parent', x: 0, y: 0, params: {} }, + { id: 'ppos', type: 'GetPosition', x: 0, y: 0, params: {} }, + { id: 'log1', type: 'Log', x: 0, y: 0, params: {} }, + { id: 'log2', type: 'Log', x: 0, y: 0, params: {} }, + { id: 'log3', type: 'Log', x: 0, y: 0, params: {} }, + ], + edges: [ + { id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'log1', port: 'exec' } }, + { id: 'e2', from: { node: 'log1', port: 'exec' }, to: { node: 'log2', port: 'exec' } }, + { id: 'e3', from: { node: 'log2', port: 'exec' }, to: { node: 'log3', port: 'exec' } }, + { id: 'e4', from: { node: 'self', port: 'object' }, to: { node: 'pos', port: 'object' } }, + { id: 'e5', from: { node: 'self', port: 'object' }, to: { node: 'par', port: 'object' } }, + { id: 'e6', from: { node: 'par', port: 'parent' }, to: { node: 'ppos', port: 'object' } }, + { id: 'e7', from: { node: 'pos', port: 'worldX' }, to: { node: 'log1', port: 'message' } }, + { id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } }, + { id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } }, + ], + }; const ctx = fakeContext({ self: child, owner: parent, variables: {} }); new GraphInterpreter(graph).fire('OnStart', ctx); expect(ctx.logs).toEqual(['105', '5', '100']); diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index ffd1f499..fbefa686 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -4,8 +4,7 @@ * The history tests pin that a rejected undo leaves the stacks usable. */ import { describe, expect, it } from 'vitest'; -import type { Command } from '../../src/core/commands/Command'; -import { CommandError } from '../../src/core/commands/Command'; +import { type Command, CommandError } from '../../src/core/commands/Command'; import { CommandStack } from '../../src/core/commands/CommandStack'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; @@ -29,7 +28,11 @@ function containerWithOverride() { describe('object holders', () => { it('counts scenes and container assets alike', () => { const { project } = containerWithOverride(); - expect(objectHolders(project).map((h) => h.type).sort()).toEqual(['container', 'scene']); + expect( + objectHolders(project) + .map((h) => h.type) + .sort(), + ).toEqual(['container', 'scene']); }); it('finds usages inside container templates, not just in scenes', () => { diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts new file mode 100644 index 00000000..7ccb02e8 --- /dev/null +++ b/tests/core/node-catalog.test.ts @@ -0,0 +1,618 @@ +/** + * One table covering every built-in node that has a `run`, plus a guard that keeps it that way: + * adding a node without a case here fails `covers every built-in node`. Nodes without `run` + * (the events) are seeded by the interpreter, so they are listed with the test that drives them + * and checked in tests/core/behavior-events.test.ts. + */ +import { describe, expect, it } from 'vitest'; +import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; +import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph'; +import type { PropertyBag } from '../../src/core/model/Properties'; +import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER } from '../helpers'; + +interface Case { + /** What the case demonstrates – becomes the failure message. */ + name: string; + inputs?: Record; + params?: PropertyBag; + context?: Partial; + /** Expected data outputs, compared exactly. */ + outputs?: Record; + /** Expected exec continuation; `null` asserts the flow ends here. */ + next?: string | string[] | null; + /** Assertions on what the node did to the context or to the handles it was given. */ + after?(ctx: FakeContext): void; +} + +const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => { + const ctx = fakeContext(c.context); + return { result: requireNodeDef(type).run!(ctx, c.inputs ?? {}, c.params ?? {}), ctx }; +}; + +/** A handle with a fixed position, for the geometry nodes. */ +const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y }); + +const pointer = (overrides: Partial): Partial => ({ + pointer: { ...NO_POINTER, ...overrides }, +}); + +const CASES: Record = { + // --- flow ------------------------------------------------------------------------------------ + Branch: [ + { name: 'true takes then', inputs: { condition: true }, next: 'then' }, + { name: 'false takes else', inputs: { condition: false }, next: 'else' }, + { name: 'a non-empty string is true', inputs: { condition: 'yes' }, next: 'then' }, + { name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' }, + ], + Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }], + + // --- variables ------------------------------------------------------------------------------- + GetVariable: [ + { name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } }, + { name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } }, + { name: 'no variable chosen reads as null', outputs: { value: null } }, + ], + SetVariable: [ + { + name: 'writes a variable, keeping the declared type', + inputs: { value: '250' }, + params: { variable: 'speed' }, + next: 'exec', + after: (ctx) => expect(ctx.variables.speed).toBe(250), + }, + { + name: 'no variable chosen writes nothing', + inputs: { value: 5 }, + next: 'exec', + after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }), + }, + ], + + // --- references and properties ---------------------------------------------------------------- + Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }], + Owner: [ + { name: 'outputs the object the behavior is parented to', outputs: { object: fakeContext().owner } }, + ], + Parent: [ + { name: 'no object has no parent', inputs: { object: null }, outputs: { parent: null } }, + { + name: 'a top-level object has no parent', + inputs: { object: fakeHandle({}) }, + outputs: { parent: null }, + }, + ], + ObjectByName: [ + { + name: 'asks the context for the name', + params: { name: 'Player' }, + context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) }, + outputs: { object: fakeEntity('Player') }, + }, + { name: 'an unknown name is null', params: { name: 'Ghost' }, outputs: { object: null } }, + ], + NameOf: [ + { name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } }, + { name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } }, + ], + IsAsset: [ + { + name: 'matches the asset id', + inputs: { object: fakeEntity('Coin', 0, 'coin') }, + params: { asset: 'coin' }, + outputs: { result: true }, + }, + { + name: 'no asset chosen never matches', + inputs: { object: fakeEntity('Coin', 0, 'coin') }, + outputs: { result: false }, + }, + ], + IsEnabled: [ + { name: 'on by default', inputs: { object: fakeHandle({}) }, outputs: { result: true, own: true } }, + { + name: 'own switch off', + inputs: { object: fakeHandle({ enabled: false }) }, + outputs: { result: false, own: false }, + }, + { + name: 'own switch on but a parent is off – "own" still reports the own switch', + inputs: { object: fakeHandle({ enabled: true }, fakeHandle({ enabled: false })) }, + outputs: { result: false, own: true }, + }, + { name: 'no object is off', inputs: { object: null }, outputs: { result: false, own: false } }, + ], + GetProperty: [ + { + name: 'reads a property', + inputs: { object: fakeHandle({ width: 42 }) }, + params: { property: 'width' }, + outputs: { value: 42 }, + }, + { + name: 'an absent property is null', + inputs: { object: fakeHandle({}) }, + params: { property: 'width' }, + outputs: { value: null }, + }, + ], + GetPosition: [ + { + name: 'local and world position of a top-level object are the same', + inputs: { object: at('A', 10, 20) }, + outputs: { x: 10, y: 20, worldX: 10, worldY: 20 }, + }, + { + name: 'a child reports its local position and the summed world position', + inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } }, + outputs: { x: 10, y: 20, worldX: 110, worldY: 220 }, + }, + { + name: 'no object is the origin', + inputs: { object: null }, + outputs: { x: 0, y: 0, worldX: 0, worldY: 0 }, + }, + ], + + // --- literals and math ------------------------------------------------------------------------- + Number: [ + { name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } }, + { name: 'unset is 0', outputs: { value: 0 } }, + ], + Boolean: [ + { name: 'outputs its setting', params: { value: true }, outputs: { value: true } }, + { name: 'unset is false', outputs: { value: false } }, + ], + String: [ + { name: 'outputs its setting', params: { value: 'hello' }, outputs: { value: 'hello' } }, + { name: 'unset is empty', outputs: { value: '' } }, + ], + Add: [ + { name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } }, + { name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } }, + ], + Subtract: [{ name: 'subtracts', inputs: { a: 2, b: 3 }, outputs: { result: -1 } }], + Multiply: [{ name: 'multiplies', inputs: { a: 2, b: 3 }, outputs: { result: 6 } }], + Divide: [ + { name: 'divides', inputs: { a: 6, b: 4 }, outputs: { result: 1.5 } }, + { name: 'division by zero is 0, not Infinity', inputs: { a: 6, b: 0 }, outputs: { result: 0 } }, + ], + Min: [{ name: 'the smaller one', inputs: { a: 2, b: -3 }, outputs: { result: -3 } }], + Max: [{ name: 'the larger one', inputs: { a: 2, b: -3 }, outputs: { result: 2 } }], + Abs: [{ name: 'drops the sign', inputs: { value: -4 }, outputs: { result: 4 } }], + Sqrt: [ + { name: 'square root', inputs: { value: 9 }, outputs: { result: 3 } }, + { name: 'negative input is 0, not NaN', inputs: { value: -9 }, outputs: { result: 0 } }, + ], + Clamp: [ + { + name: 'inside the range passes through', + inputs: { value: 0.4, min: 0, max: 1 }, + outputs: { result: 0.4 }, + }, + { name: 'above the range', inputs: { value: 5, min: 0, max: 1 }, outputs: { result: 1 } }, + { name: 'below the range', inputs: { value: -5, min: 0, max: 1 }, outputs: { result: 0 } }, + ], + Floor: [ + { name: 'rounds down', inputs: { value: 2.9 }, outputs: { result: 2 } }, + { name: 'rounds down for negatives too', inputs: { value: -2.1 }, outputs: { result: -3 } }, + ], + Ceil: [ + { name: 'rounds up', inputs: { value: 2.1 }, outputs: { result: 3 } }, + { name: 'a whole number stays put', inputs: { value: 3 }, outputs: { result: 3 } }, + ], + Round: [ + { name: 'rounds to nearest', inputs: { value: 2.4 }, outputs: { result: 2 } }, + { name: 'half rounds up', inputs: { value: 2.5 }, outputs: { result: 3 } }, + { name: 'negative half rounds towards zero', inputs: { value: -2.5 }, outputs: { result: -2 } }, + ], + Modulo: [ + { name: 'remainder', inputs: { a: 125, b: 60 }, outputs: { result: 5 } }, + { name: 'modulo zero is 0, not NaN', inputs: { a: 125, b: 0 }, outputs: { result: 0 } }, + ], + Random: [ + // The spread is checked in "Random stays inside [min, max)" below; an empty range is the + // one case with a single right answer. + { name: 'an empty range always yields min', inputs: { min: 3, max: 3 }, outputs: { result: 3 } }, + ], + Time: [ + { + name: 'reports the clock and the frame length from the context', + context: { time: 12.5, dt: 0.016 }, + outputs: { time: 12.5, dt: 0.016 }, + }, + ], + + // --- text and comparison ----------------------------------------------------------------------- + ToText: [ + { name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } }, + { name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } }, + { name: 'decimals are clamped to 0…10', inputs: { value: 1, decimals: -5 }, outputs: { text: '1' } }, + ], + Join: [ + { name: 'sticks values together as text', inputs: { a: 'Coins: ', b: 3 }, outputs: { text: 'Coins: 3' } }, + { + name: 'an object joins as its name', + inputs: { a: '', b: fakeEntity('Coin') }, + outputs: { text: 'Coin' }, + }, + ], + Equals: [ + { name: 'equal text', inputs: { a: 'a', b: 'a' }, outputs: { result: true } }, + { name: 'a number equals its text form', inputs: { a: 3, b: '3' }, outputs: { result: true } }, + { name: 'objects compare by identity, not by name', inputs: eqNamesakes(), outputs: { result: false } }, + ], + Compare: [ + { name: 'less than', inputs: { a: 1, b: 2 }, params: { op: '<' }, outputs: { result: true } }, + { name: 'greater or equal', inputs: { a: 2, b: 2 }, params: { op: '>=' }, outputs: { result: true } }, + { name: 'not equal', inputs: { a: 2, b: 2 }, params: { op: '!=' }, outputs: { result: false } }, + { + name: 'no operator chosen falls back to the default "<"', + inputs: { a: 1, b: 2 }, + outputs: { result: true }, + }, + ], + And: [ + { name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } }, + { name: 'one false', inputs: { a: true, b: false }, outputs: { result: false } }, + { name: 'an unconnected input reads as false', inputs: { a: true }, outputs: { result: false } }, + ], + Or: [ + { name: 'one true is enough', inputs: { a: false, b: true }, outputs: { result: true } }, + { name: 'both false', inputs: { a: false, b: false }, outputs: { result: false } }, + ], + Not: [ + { name: 'inverts', inputs: { value: true }, outputs: { result: false } }, + { name: 'inverts the other way', inputs: { value: false }, outputs: { result: true } }, + { name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } }, + ], + + // --- vectors and input -------------------------------------------------------------------------- + Length: [ + { name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } }, + { name: 'the zero vector has length 0', inputs: { x: 0, y: 0 }, outputs: { result: 0 } }, + ], + Normalize: [ + { + name: 'scales to length 1 and reports the old length', + inputs: { x: 0, y: -4 }, + outputs: { x: 0, y: -1, length: 4 }, + }, + { + name: 'the zero vector stays zero instead of becoming NaN', + inputs: { x: 0, y: 0 }, + outputs: { x: 0, y: 0, length: 0 }, + }, + ], + Distance: [ + { + name: 'distance between two objects', + inputs: { a: at('a', 0, 0), b: at('b', 3, 4) }, + outputs: { result: 5 }, + }, + { name: 'a missing object is distance 0', inputs: { a: at('a', 0, 0), b: null }, outputs: { result: 0 } }, + ], + AxisInput: [ + { + name: 'one key gives a unit vector', + context: { isKeyDown: (key) => key === 'ArrowRight' }, + outputs: { x: 1, y: 0, active: true }, + }, + { + name: 'diagonals are normalized, so they are not faster', + context: { isKeyDown: (key) => key === 'ArrowRight' || key === 'ArrowDown' }, + // Not Math.SQRT1_2: the node divides by Math.hypot(1, 1), which lands one ulp lower. + outputs: { x: 1 / Math.hypot(1, 1), y: 1 / Math.hypot(1, 1), active: true }, + }, + { + name: 'opposite keys cancel out', + context: { isKeyDown: (key) => key === 'ArrowLeft' || key === 'ArrowRight' }, + outputs: { x: 0, y: 0, active: false }, + }, + { name: 'nothing held is inactive', outputs: { x: 0, y: 0, active: false } }, + ], + KeyIsDown: [ + { + name: 'asks the context for the chosen key', + params: { key: 'a' }, + context: { isKeyDown: (k) => k === 'a' }, + outputs: { down: true }, + }, + { + name: 'another key is not down', + params: { key: 'b' }, + context: { isKeyDown: (k) => k === 'a' }, + outputs: { down: false }, + }, + { name: 'no key chosen is never down', context: { isKeyDown: () => true }, outputs: { down: true } }, + ], + MousePosition: [ + { + name: 'reports the pointer in world units', + context: pointer({ x: 40, y: -20, inside: true }), + outputs: { x: 40, y: -20, inside: true }, + }, + { + name: 'outside the camera frame the position is still reported, but "in view" is false', + context: pointer({ x: 5, y: 5, inside: false }), + outputs: { x: 5, y: 5, inside: false }, + }, + ], + MouseButton: [ + { + name: 'the frame the button goes down', + context: pointer({ down: true, pressed: true }), + outputs: { down: true, pressed: true, released: false }, + }, + { + name: 'a later frame while it is still held', + context: pointer({ down: true }), + outputs: { down: true, pressed: false, released: false }, + }, + { + name: 'the frame it goes up', + context: pointer({ released: true }), + outputs: { down: false, pressed: false, released: true }, + }, + ], + IsHovered: [ + { + name: 'the pointer is over the object', + inputs: { object: fakeEntity('Button') }, + context: { ...pointer({ inside: true }), hitTest: () => true }, + outputs: { result: true }, + }, + { + name: 'a hit outside the camera frame does not count', + inputs: { object: fakeEntity('Button') }, + context: { ...pointer({ inside: false }), hitTest: () => true }, + outputs: { result: false }, + }, + { + name: 'no object is never hovered', + inputs: { object: null }, + context: { ...pointer({ inside: true }), hitTest: () => true }, + outputs: { result: false }, + }, + ], + IsTouching: [ + { + name: 'asks the context', + inputs: { a: fakeEntity('a'), b: fakeEntity('b') }, + context: { isTouching: () => true }, + outputs: { result: true }, + }, + { + name: 'a missing object never touches', + inputs: { a: fakeEntity('a'), b: null }, + context: { isTouching: () => true }, + outputs: { result: false }, + }, + ], + + // --- actions -------------------------------------------------------------------------------------- + Move: [ + { + name: 'moves by (dx, dy) × scale × dt', + inputs: { object: at('m', 0, 0), dx: 1, dy: 0, scale: 100, dt: 0.5 }, + next: 'exec', + outputs: { blockedX: false, blockedY: false }, + }, + { + name: 'no object is a no-op that still continues the flow', + inputs: { object: null, dx: 1, dy: 1 }, + next: 'exec', + outputs: { blockedX: false, blockedY: false }, + }, + ], + SetPosition: [ + { + name: 'places the object and continues', + inputs: { object: at('p', 0, 0), x: 30, y: -40 }, + next: 'exec', + }, + { name: 'no object is a no-op', inputs: { object: null, x: 1, y: 1 }, next: 'exec' }, + ], + SetProperty: [ + { + name: 'writes a property, keeping the existing type', + inputs: { object: fakeHandle({ width: 100 }), value: '250' }, + params: { property: 'width' }, + next: 'exec', + }, + { + name: 'no property name chosen writes nothing', + inputs: { object: fakeHandle({ width: 100 }), value: 5 }, + next: 'exec', + }, + ], + SetEnabled: [ + { + name: 'switches an object off', + inputs: { object: fakeHandle({ enabled: true }), enabled: false }, + next: 'exec', + }, + { name: 'no object is a no-op', inputs: { object: null, enabled: false }, next: 'exec' }, + ], + SetText: [ + { + name: 'writes the content of a text object', + inputs: { object: fakeHandle({ text: 'a' }), text: 12 }, + next: 'exec', + }, + ], + Spawn: [ + { + name: 'no asset chosen logs a hint and continues with no object', + next: 'exec', + outputs: { object: null }, + after: (ctx) => expect(ctx.logs).toEqual(['Spawn: no asset selected']), + }, + ], + Destroy: [ + { + name: 'no object is a no-op that still continues the flow', + inputs: { object: null }, + next: 'exec', + }, + ], + Log: [ + { + name: 'logs its message and continues', + inputs: { message: 'hi' }, + next: 'exec', + after: (ctx) => expect(ctx.logs).toEqual(['hi']), + }, + { + name: 'an object logs as its name', + inputs: { message: fakeEntity('Coin') }, + next: 'exec', + after: (ctx) => expect(ctx.logs).toEqual(['Coin']), + }, + ], + StopGame: [{ name: 'ends play and the flow', next: null, after: (ctx) => expect(ctx.stopped).toBe(true) }], + RestartGame: [{ name: 'ends the flow – the world restarts before the next tick', next: null }], +}; + +/** Two distinct handles that share a name – `Equals` must still tell them apart. */ +function eqNamesakes(): Record { + return { a: fakeEntity('Coin'), b: fakeEntity('Coin') }; +} + +/** + * Nodes without a `run`: the interpreter seeds their outputs when the BehaviorSystem fires them, + * so they are only observable through a running world. The value says which test drives it. + */ +const EVENT_NODES: Record = { + OnStart: 'graph.test.ts', + OnUpdate: 'graph.test.ts', + OnKeyDown: 'behavior-events.test.ts', + OnKeyUp: 'behavior-events.test.ts', + OnKeyHeld: 'behavior-events.test.ts', + OnTimer: 'behavior-events.test.ts', + OnCollisionEnter: 'collider.test.ts', + OnCollisionStay: 'behavior-events.test.ts', + OnCollisionExit: 'collider.test.ts', + OnClick: 'nodes.test.ts', +}; + +describe('node catalog', () => { + it('covers every built-in node', () => { + const uncovered = BUILT_IN_NODES.filter((def) => + def.run ? !CASES[def.type] : !EVENT_NODES[def.type], + ).map((def) => def.type); + expect(uncovered).toEqual([]); + }); + + it('lists exactly the run-less nodes as events', () => { + const runless = BUILT_IN_NODES.filter((def) => !def.run).map((def) => def.type); + expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort()); + }); + + it('declares well-formed ports and settings', () => { + for (const def of BUILT_IN_NODES) { + const keys = [...def.inputs, ...def.outputs].map((p) => p.key); + expect(def.label, `${def.type} has a label`).not.toBe(''); + expect(def.description, `${def.type} has a description`).not.toBe(''); + // Inputs and outputs are addressed by key within their own side; a key may repeat across + // sides (Normalize reads x/y and writes x/y), never within one. + expect(new Set(def.inputs.map((p) => p.key)).size, `${def.type} input keys unique`).toBe( + def.inputs.length, + ); + expect(new Set(def.outputs.map((p) => p.key)).size, `${def.type} output keys unique`).toBe( + def.outputs.length, + ); + expect( + keys.every((k) => k !== ''), + `${def.type} has no empty port key`, + ).toBe(true); + for (const param of def.params) { + expect(param.default, `${def.type}.${param.key} has a default`).toBeDefined(); + } + } + }); +}); + +for (const [type, cases] of Object.entries(CASES)) { + describe(type, () => { + for (const c of cases) { + it(c.name, () => { + const { result, ctx } = run(type, c); + if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs); + if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next); + c.after?.(ctx); + }); + } + }); +} + +// Cases whose point is the mutation they cause – stated here rather than squeezed into the table. +describe('action nodes write through to the object they are given', () => { + it('Move applies dx/dy × scale × dt', () => { + const target = at('m', 0, 0); + requireNodeDef('Move').run!(fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {}); + expect({ x: target.x, y: target.y }).toEqual({ x: 50, y: -100 }); + }); + + it('SetPosition places the object absolutely', () => { + const target = at('p', 10, 10); + requireNodeDef('SetPosition').run!(fakeContext(), { object: target, x: 30, y: -40 }, {}); + expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 }); + }); + + it('SetProperty keeps the type the property already had', () => { + const target = fakeHandle({ width: 100 }); + requireNodeDef('SetProperty').run!( + fakeContext(), + { object: target, value: '250' }, + { property: 'width' }, + ); + expect(target.props.width).toBe(250); + }); + + it('SetProperty without a property name writes nothing', () => { + const target = fakeHandle({ width: 100 }); + requireNodeDef('SetProperty').run!(fakeContext(), { object: target, value: 5 }, {}); + expect(target.props).toEqual({ width: 100 }); + }); + + it('SetEnabled switches the object off', () => { + const target = fakeHandle({ enabled: true }); + requireNodeDef('SetEnabled').run!(fakeContext(), { object: target, enabled: false }, {}); + expect(target.props.enabled).toBe(false); + }); + + it('SetText writes the text property, stringifying numbers', () => { + const target = fakeHandle({ text: 'a' }); + requireNodeDef('SetText').run!(fakeContext(), { object: target, text: 12 }, {}); + expect(target.props.text).toBe('12'); + }); + + it('Destroy hands the object to the context', () => { + const removed: string[] = []; + const ctx = fakeContext({ destroy: (target) => removed.push(target.name) }); + requireNodeDef('Destroy').run!(ctx, { object: fakeEntity('Coin') }, {}); + expect(removed).toEqual(['Coin']); + }); + + it('RestartGame asks the context for a restart', () => { + let restarted = false; + const ctx = fakeContext({ + restart: () => { + restarted = true; + }, + }); + requireNodeDef('RestartGame').run!(ctx, {}, {}); + expect(restarted).toBe(true); + }); + + it('Random stays inside [min, max) over many draws', () => { + const ctx = fakeContext(); + const draws = Array.from( + { length: 200 }, + () => requireNodeDef('Random').run!(ctx, { min: 5, max: 10 }, {}).outputs!.result as number, + ); + expect(draws.every((n) => n >= 5 && n < 10)).toBe(true); + // Not a constant: a generator stuck on one value would pass the range check above. + expect(new Set(draws).size).toBeGreaterThan(1); + }); +}); diff --git a/tests/core/nodes.test.ts b/tests/core/nodes.test.ts index f5273677..ce2e67fc 100644 --- a/tests/core/nodes.test.ts +++ b/tests/core/nodes.test.ts @@ -1,39 +1,18 @@ import { describe, expect, it } from 'vitest'; import { type Graph, GraphInterpreter } from '../../src/core/graph'; -import { fakeContext, fakeEntity, fakeHandle, graphEditor, makeBehavior, makeSpriteScene, place, runNode, startWorld } from '../helpers'; +import { + fakeContext, + fakeEntity, + graphEditor, + makeBehavior, + makeSpriteScene, + place, + startWorld, +} from '../helpers'; +// Node-by-node behaviour lives in node-catalog.test.ts; what is left here needs a running +// world (On click's hit test) or a whole flow (Spawn feeding its object downstream). describe('game-flow nodes', () => { - it('formats numbers and joins text', () => { - expect(runNode('ToText', { value: 12.345, decimals: 1 })).toEqual({ text: '12.3' }); - expect(runNode('ToText', { value: 'x', decimals: 0 })).toEqual({ text: 'x' }); - expect(runNode('Join', { a: 'Coins: ', b: 3 })).toEqual({ text: 'Coins: 3' }); - expect(runNode('Modulo', { a: 125, b: 60 })).toEqual({ result: 5 }); - expect(runNode('Modulo', { a: 5, b: 0 })).toEqual({ result: 0 }); - expect(runNode('Floor', { value: 2.9 })).toEqual({ result: 2 }); - }); - - it('compares values and identifies assets', () => { - const coin = fakeHandle({}); - coin.assetId = 'coin'; - expect(runNode('IsAsset', { object: coin }, { asset: 'coin' })).toEqual({ result: true }); - expect(runNode('IsAsset', { object: coin }, { asset: 'other' })).toEqual({ result: false }); - expect(runNode('IsAsset', { object: null }, { asset: 'coin' })).toEqual({ result: false }); - expect(runNode('Equals', { a: 'a', b: 'a' })).toEqual({ result: true }); - expect(runNode('Equals', { a: coin, b: coin })).toEqual({ result: true }); - expect(runNode('Equals', { a: coin, b: null })).toEqual({ result: false }); - expect(runNode('NameOf', { object: coin })).toEqual({ name: 'h' }); - }); - - it('switches objects on and off and asks for a restart', () => { - const target = fakeHandle({ enabled: true }); - runNode('SetEnabled', { object: target, enabled: false }); - expect(target.props.enabled).toBe(false); - - let restarted = false; - runNode('RestartGame', {}, {}, fakeContext({ restart: () => { restarted = true; } })); - expect(restarted).toBe(true); - }); - it('fires On click when the mouse is pressed on the owner', () => { const f = makeSpriteScene('Button'); const behavior = makeBehavior(f, 'OnPress', ['clicks']); @@ -101,7 +80,10 @@ describe('Spawn node', () => { it('logs a hint and continues when no asset is selected or the asset is unknown', () => { const ctx = fakeContext({ spawn: () => null }); - const noAsset: Graph = { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)) }; + const noAsset: Graph = { + ...graph, + nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)), + }; new GraphInterpreter(noAsset).fire('OnStart', ctx); expect(ctx.logs).toEqual(['Spawn: no asset selected', '']); const ctx2 = fakeContext({ spawn: () => null }); diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts index 26bc0916..9ddf706e 100644 --- a/tests/core/resolve.test.ts +++ b/tests/core/resolve.test.ts @@ -48,7 +48,12 @@ describe('resolve', () => { const sprite = buildAsset('sprite', 'Box'); sprite.props.color = 0x00ff00; const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 }); - expect(resolveSceneObjectProps(sprite, object)).toEqual({ enabled: true, width: 100, height: 100, color: 0x00ff00 }); + expect(resolveSceneObjectProps(sprite, object)).toEqual({ + enabled: true, + width: 100, + height: 100, + color: 0x00ff00, + }); object.overrides.color = 0x0000ff; expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index 58593247..a8b032d4 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -17,11 +17,22 @@ describe('serialize', () => { name: 'Broken', activeSceneId: 'missing', assets: [ - { id: 's1', type: 'scene', name: 'Scene', props: { backgroundColor: '#000000', junk: 1 }, objects: [ - { id: 'o1', assetId: 'sp1', position: { x: '5', y: null }, overrides: { color: '#00ff00', width: -3, foo: 1 } }, - { id: 'o2', assetId: 'nope', position: {} }, - { id: 'o3', assetId: 's1', position: {} }, - ] }, + { + id: 's1', + type: 'scene', + name: 'Scene', + props: { backgroundColor: '#000000', junk: 1 }, + objects: [ + { + id: 'o1', + assetId: 'sp1', + position: { x: '5', y: null }, + overrides: { color: '#00ff00', width: -3, foo: 1 }, + }, + { id: 'o2', assetId: 'nope', position: {} }, + { id: 'o3', assetId: 's1', position: {} }, + ], + }, { id: 'sp1', type: 'sprite', props: { width: 10 } }, { id: 'x', type: 'texture' }, { type: 'camera' }, diff --git a/tests/core/world-sync.test.ts b/tests/core/world-sync.test.ts index 8c36e934..f4d0826e 100644 --- a/tests/core/world-sync.test.ts +++ b/tests/core/world-sync.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; -import { ClearOverrideCommand, RemoveSceneObjectCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { + ClearOverrideCommand, + RemoveSceneObjectCommand, + SetOverrideCommand, + SetParentCommand, +} from '../../src/core/commands/sceneObjectCommands'; import { findScene } from '../../src/core/model/Project'; import { World } from '../../src/runtime/World'; import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; diff --git a/tests/helpers.ts b/tests/helpers.ts index b9d2c656..539f82cc 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -3,7 +3,14 @@ import { CommandStack } from '../src/core/commands/CommandStack'; import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphCommands'; import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../src/core/commands/variableCommands'; -import { type EntityHandle, type GraphNode, type NodeContext, type PointerState, type Value, requireNodeDef } from '../src/core/graph'; +import { + type EntityHandle, + type GraphNode, + type NodeContext, + type PointerState, + type Value, + requireNodeDef, +} from '../src/core/graph'; import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset'; import { findObject, worldPosition } from '../src/core/model/hierarchy'; import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties'; @@ -45,18 +52,34 @@ export function makeSpriteScene(spriteName = 'Box'): SceneFixture { fixture.stack.execute(sprite); const sceneOf = () => findScene(fixture.project, scene.asset.id)!; const object = (id: string) => findObject(sceneOf(), id)!; - return { ...fixture, sceneId: scene.asset.id, spriteId: sprite.asset.id, sceneOf, object, world: (id) => worldPosition(sceneOf(), object(id)) }; + return { + ...fixture, + sceneId: scene.asset.id, + spriteId: sprite.asset.id, + sceneOf, + object, + world: (id) => worldPosition(sceneOf(), object(id)), + }; } /** Places an asset into the fixture's scene at a world position (optionally below a parent) and returns the new object's id. */ -export function place(fixture: SceneFixture, assetId: string, position: Vec2 | Position, parentId = ''): string { +export function place( + fixture: SceneFixture, + assetId: string, + position: Vec2 | Position, + parentId = '', +): string { const command = new PlaceAssetCommand(fixture.sceneId, assetId, position, parentId); fixture.stack.execute(command); return command.objectId; } /** Adds a behavior asset declaring the given number variables. */ -export function makeBehavior(fixture: ProjectFixture, name = 'B', variables: readonly string[] = []): BehaviorAsset { +export function makeBehavior( + fixture: ProjectFixture, + name = 'B', + variables: readonly string[] = [], +): BehaviorAsset { const command = new CreateAssetCommand('behavior', name); fixture.stack.execute(command); for (const key of variables) fixture.stack.execute(new AddVariableCommand(command.asset.id, key, 'number')); @@ -76,7 +99,9 @@ export interface GraphEditor { export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): GraphEditor { const { stack } = fixture; const connect: GraphEditor['connect'] = (from, fromPort, to, toPort) => { - stack.execute(new ConnectCommand(behavior.id, { node: from.id, port: fromPort }, { node: to.id, port: toPort })); + stack.execute( + new ConnectCommand(behavior.id, { node: from.id, port: fromPort }, { node: to.id, port: toPort }), + ); }; return { addNode(type, params = {}) { @@ -91,7 +116,14 @@ export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): G }; } -export const NO_POINTER: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false }; +export const NO_POINTER: PointerState = { + x: 0, + y: 0, + inside: false, + down: false, + pressed: false, + released: false, +}; /** A top-level entity handle named after itself, with empty props. */ export function fakeEntity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle { @@ -139,7 +171,12 @@ export function fakeContext(overrides: Partial = {}): FakeContext { } /** Runs one node definition in isolation and returns its data outputs. */ -export function runNode(type: string, inputs: Record, params: PropertyBag = {}, ctx: NodeContext = fakeContext()): Record { +export function runNode( + type: string, + inputs: Record, + params: PropertyBag = {}, + ctx: NodeContext = fakeContext(), +): Record { return requireNodeDef(type).run!(ctx, inputs, params).outputs ?? {}; } @@ -148,15 +185,36 @@ export interface WorldOptions { behaviors?: boolean; } +export interface StartedWorld { + world: World; + collisions: CollisionSystem | null; + /** Feed keys with `input.press('a')`; `frame()` clears the edges the way the editor does. */ + input: Input; + /** One tick plus the end-of-frame bookkeeping the editor's play loop performs. */ + frame(dt?: number): void; +} + /** Instantiates the fixture's scene, optionally with a CollisionSystem and a BehaviorSystem (fed by those collisions). */ -export function startWorld(fixture: SceneFixture, options: WorldOptions & { collisions: true }): { world: World; collisions: CollisionSystem }; -export function startWorld(fixture: SceneFixture, options?: WorldOptions): { world: World; collisions: CollisionSystem | null }; -export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): { world: World; collisions: CollisionSystem | null } { +export function startWorld( + fixture: SceneFixture, + options: WorldOptions & { collisions: true }, +): StartedWorld & { collisions: CollisionSystem }; +export function startWorld(fixture: SceneFixture, options?: WorldOptions): StartedWorld; +export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): StartedWorld { const world = World.instantiate(fixture.project, fixture.sceneId); const collisions = options.collisions ? new CollisionSystem() : null; + const input = new Input(); if (collisions) world.addSystem(collisions); - if (options.behaviors) world.addSystem(new BehaviorSystem(fixture.project, new Input(), collisions)); - return { world, collisions }; + if (options.behaviors) world.addSystem(new BehaviorSystem(fixture.project, input, collisions)); + return { + world, + collisions, + input, + frame(dt = 1 / 60) { + world.tick(dt); + input.endFrame(); + }, + }; } /** The project as it comes back after being saved and loaded again. */ diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index 7df51cab..af284f95 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -3,7 +3,14 @@ * checked through the browser. They are plain geometry, so they are pinned here. */ import { describe, expect, it } from 'vitest'; -import { cameraView, freeView, isInside, screenToWorld, worldBounds, worldToScreen } from '../../src/viewport/view'; +import { + cameraView, + freeView, + isInside, + screenToWorld, + worldBounds, + worldToScreen, +} from '../../src/viewport/view'; describe('freeView', () => { it('puts the view centre in the middle of the widget', () => { @@ -19,7 +26,7 @@ describe('freeView', () => { it('round-trips any point', () => { const view = freeView(13, -7, 0.35, 640, 480); - const back = screenToWorld(view, ...Object.values(worldToScreen(view, 123, -456)) as [number, number]); + const back = screenToWorld(view, ...(Object.values(worldToScreen(view, 123, -456)) as [number, number])); expect(back.x).toBeCloseTo(123, 9); expect(back.y).toBeCloseTo(-456, 9); }); diff --git a/tools/e2e/01-hierarchy.mjs b/tools/e2e/01-hierarchy.mjs index 94b2225b..da1ceba5 100644 --- a/tools/e2e/01-hierarchy.mjs +++ b/tools/e2e/01-hierarchy.mjs @@ -11,7 +11,11 @@ export async function hierarchy(t) { const rows = async () => JSON.stringify(await rowIds()); const selectionBefore = (await state()).selection; - check('hierarchy starts collapsed – only top-level objects are listed', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows()); + check( + 'hierarchy starts collapsed – only top-level objects are listed', + same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), + await rows(), + ); check('a leaf row offers no chevron', (await evaluate(`__gm.hierarchyChevron('o3')`)) === null); const chevron = await json(`__gm.hierarchyChevron('o2')`); await click(chevron.x, chevron.y); @@ -22,10 +26,18 @@ export async function hierarchy(t) { ); await shot('01a-hierarchy-unfolded'); await click(chevron.x, chevron.y); - check('clicking it again folds the subtree away', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows()); + check( + 'clicking it again folds the subtree away', + same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), + await rows(), + ); await select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' }); await sleep(150); - check('selecting a nested object unfolds its ancestors', same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), await rows()); + check( + 'selecting a nested object unfolds its ancestors', + same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), + await rows(), + ); await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); await sleep(150); check('Collapse all folds everything back', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows()); diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index 0102589a..5df7e612 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -3,12 +3,34 @@ * the server, and undo/redo of all of it. */ export async function inspector(t) { - const { check, sleep, json, locate, shot, click, clickText, drag, press, typeText, undo, redo, state, object, api, sceneOf, projectId } = t; + const { + check, + sleep, + json, + locate, + shot, + click, + clickText, + drag, + press, + typeText, + undo, + redo, + state, + object, + api, + sceneOf, + projectId, + } = t; // 1. Select the blue crate in the hierarchy. await clickText('Blue crate'); let s = await state(); - check('hierarchy click selects object', s.selection?.kind === 'object' && s.selection.objectId === 'o2', JSON.stringify(s.selection)); + check( + 'hierarchy click selects object', + s.selection?.kind === 'object' && s.selection.objectId === 'o2', + JSON.stringify(s.selection), + ); await shot('02-object-selected'); // 2. Rename via inspector: the name field sits right of the "Name" label. @@ -19,7 +41,11 @@ export async function inspector(t) { await shot('03-typing'); await press('Enter'); await sleep(200); - check('typing + Enter renamed the object', (await object('o2')).name === 'Renamed crate', JSON.stringify((await object('o2')).name)); + check( + 'typing + Enter renamed the object', + (await object('o2')).name === 'Renamed crate', + JSON.stringify((await object('o2')).name), + ); await shot('04-renamed'); // 3. Scrub the X field (right of the "X" label) by 100 px. @@ -37,7 +63,11 @@ export async function inspector(t) { await click(center.x, center.y + 250); const xTyped = (await object('o2')).position.x; s = await state(); - check('typed X survives clicking into the viewport', xTyped === 300 && s.selection === null, `x=${xTyped} selection=${JSON.stringify(s.selection)}`); + check( + 'typed X survives clicking into the viewport', + xTyped === 300 && s.selection === null, + `x=${xTyped} selection=${JSON.stringify(s.selection)}`, + ); // 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred // on the origin, so compute its screen position from the viewport centre. @@ -59,7 +89,11 @@ export async function inspector(t) { await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus for (let i = 0; i < 4; i++) await undo(); const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x })); - check('undo reverted drag, typed X, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo)); + check( + 'undo reverted drag, typed X, scrub and rename', + afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', + JSON.stringify(afterUndo), + ); await redo(); check('redo restored the rename', (await object('o2')).name === 'Renamed crate'); await shot('07-undo-redo'); diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index 8e784566..24892b87 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -7,7 +7,25 @@ const FIXTURE_IDS = ['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2']; * camera. */ export async function play(t) { - const { check, sleep, evaluate, json, shot, clickText, press, holdKey, undo, state, object, objects, world, entity, select, mutate, stop } = t; + const { + check, + sleep, + evaluate, + json, + shot, + clickText, + press, + holdKey, + undo, + state, + object, + objects, + world, + entity, + select, + mutate, + stop, + } = t; const gameInfo = () => json('__gm.gameInfo()'); const play = t.play; @@ -20,40 +38,72 @@ export async function play(t) { let s = await state(); check('play mode entered', s.mode === 'play', JSON.stringify(s)); let info = await gameInfo(); - check('play renders the camera frame with both crates', info.rendering && info.visibleItems === 2, JSON.stringify(info)); + check( + 'play renders the camera frame with both crates', + info.rendering && info.visibleItems === 2, + JSON.stringify(info), + ); await shot('08-playing'); await holdKey('ArrowRight', 400); const movedCrate = await entity('o1'); - check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`); + check( + 'behavior graph moved its owner while ArrowRight was held', + movedCrate.x > -150 + 30, + `world x=${movedCrate.x}`, + ); await holdKey('ArrowLeft', 250); const afterLeft = await entity('o1'); - check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`); + check( + 'ArrowLeft (axis x = −1) moved the owner back left', + afterLeft.x < movedCrate.x, + `world x=${afterLeft.x}`, + ); await stop(); s = await state(); check('escape stopped play', s.mode === 'edit', JSON.stringify(s)); await shot('09-stopped'); const o2AfterPlay = (await object('o2')).position.x; const o1AfterPlay = (await object('o1')).position.x; - check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`); + check( + 'play did not write back to the model', + o2AfterPlay === xBeforePlay && o1AfterPlay === -150, + `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`, + ); // 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime // copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with // the Delete key change the world at once, undo brings it back, Esc restores the saved model. await play(); check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game'); - check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7); + check( + 'panels stay visible while playing', + Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && + (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7, + ); await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' }); await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400`); let liveO2 = await entity('o2'); - check('an edit while playing moves the entity in the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2)); + check( + 'an edit while playing moves the entity in the world', + liveO2 && liveO2.x === 400, + JSON.stringify(liveO2), + ); await shot('08b-editing-while-playing'); await press('Delete'); await sleep(150); const liveIds = (await world()).entities.map((e) => e.objectId); - check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, JSON.stringify(liveIds)); + check( + 'Delete while playing removes the entity and its collider child from the world', + !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, + JSON.stringify(liveIds), + ); await undo(); liveO2 = await entity('o2'); - check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2)); + check( + 'undo while playing brings the entity back into the world', + liveO2 && liveO2.x === 400, + JSON.stringify(liveO2), + ); // 7a-ii. Spawning: the "Spawner" behavior places a Crate at (0, 150) on Space. The new object // shows up in the runtime scene (hierarchy), in the world and in the Game view at once; @@ -65,18 +115,38 @@ export async function play(t) { const spawned = spawnedObjects.find((o) => !FIXTURE_IDS.includes(o.id)); check( 'Spawn node adds a crate object to the runtime scene at (0, 150)', - spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8, + spawned && + spawned.assetId === 'crate' && + spawned.parentId === '' && + spawned.position.x === 0 && + spawned.position.y === 150 && + spawnedObjects.length === 8, JSON.stringify(spawned), ); const spawnWorld = await world(); - check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log })); - check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8); + check( + 'the spawned entity lives in the world and its name reached the Log node', + spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), + JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }), + ); + check( + 'hierarchy lists the spawned object', + (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8, + ); info = await gameInfo(); - check('the Game view renders the spawned crate', info.visibleItems === visibleBeforeSpawn + 1, `${JSON.stringify(info)} before=${visibleBeforeSpawn}`); + check( + 'the Game view renders the spawned crate', + info.visibleItems === visibleBeforeSpawn + 1, + `${JSON.stringify(info)} before=${visibleBeforeSpawn}`, + ); await shot('08c-spawned'); await stop(); const o2AfterEdit = (await object('o2')).position.x; - check('stop restores the saved model untouched by play-mode edits (spawned object gone)', o2AfterEdit === xBeforePlay && (await objects()).length === 7, `x=${o2AfterEdit}`); + check( + 'stop restores the saved model untouched by play-mode edits (spawned object gone)', + o2AfterEdit === xBeforePlay && (await objects()).length === 7, + `x=${o2AfterEdit}`, + ); check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene'); // 7b. Culling: move the blue crate far outside the camera frame → only one item visible. @@ -95,7 +165,11 @@ export async function play(t) { await play(); info = await gameInfo(); s = await state(); - check('play without camera renders nothing', s.mode === 'play' && !info.rendering && info.visibleItems === 0, JSON.stringify(info)); + check( + 'play without camera renders nothing', + s.mode === 'play' && !info.rendering && info.visibleItems === 0, + JSON.stringify(info), + ); check('play without camera shows a notice', s.notice.includes('no camera'), s.notice); await shot('10-play-no-camera'); await stop(); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 10db3062..036c0701 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -28,10 +28,18 @@ export async function assets(t) { check('asset inspector shows a reset for the non-default height', Boolean(resetButton)); await click(resetButton.x, resetButton.y); await sleep(100); - check('reset puts the asset property back to its default', (await asset('crate')).props.height === 100, `height=${(await asset('crate')).props.height}`); + check( + 'reset puts the asset property back to its default', + (await asset('crate')).props.height === 100, + `height=${(await asset('crate')).props.height}`, + ); check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`))); await shot('15-asset-reset'); await undo(); - check('undo restores the asset value', (await asset('crate')).props.height === 60, `height=${(await asset('crate')).props.height}`); + check( + 'undo restores the asset value', + (await asset('crate')).props.height === 60, + `height=${(await asset('crate')).props.height}`, + ); check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`))); } diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs index cdd1174a..718ca554 100644 --- a/tools/e2e/05-drawing.mjs +++ b/tools/e2e/05-drawing.mjs @@ -13,13 +13,23 @@ export async function drawing(t) { crate.position.x = 0; crate.position.y = 0; __gm.state.select(null)`); const layers = await json(`[__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')]`); - check('camera symbol is drawn in the overlay layer above sprites', JSON.stringify(layers) === '["overlay","objects","objects"]', JSON.stringify(layers)); + check( + 'camera symbol is drawn in the overlay layer above sprites', + JSON.stringify(layers) === '["overlay","objects","objects"]', + JSON.stringify(layers), + ); await shot('13-symbol-on-top'); // 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins // (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it. - await mutate(`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`); - check('equal z keeps hierarchy order', JSON.stringify(await orderOfCrates()) === '["o1","o2"]', JSON.stringify(await orderOfCrates())); + await mutate( + `const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`, + ); + check( + 'equal z keeps hierarchy order', + JSON.stringify(await orderOfCrates()) === '["o1","o2"]', + JSON.stringify(await orderOfCrates()), + ); await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' }); await sleep(200); const zLabel = await locate('Z'); @@ -27,8 +37,16 @@ export async function drawing(t) { await typeText('5'); await press('Enter'); await sleep(200); - check('Z field sets position.z', (await object('o1')).position.z === 5, `z=${(await object('o1')).position.z}`); - check('higher z is drawn on top', JSON.stringify(await orderOfCrates()) === '["o2","o1"]', JSON.stringify(await orderOfCrates())); + check( + 'Z field sets position.z', + (await object('o1')).position.z === 5, + `z=${(await object('o1')).position.z}`, + ); + check( + 'higher z is drawn on top', + JSON.stringify(await orderOfCrates()) === '["o2","o1"]', + JSON.stringify(await orderOfCrates()), + ); await shot('14-z-order'); await undo(); check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index b6e04990..192d9135 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -3,7 +3,25 @@ * and Enter), connecting ports, deleting, the live highlight while playing, variables. */ export async function graph(t) { - const { check, sleep, evaluate, json, shot, click, clickText, drag, locate, typeText, press, holdKey, undo, state, select, stop, asset } = t; + const { + check, + sleep, + evaluate, + json, + shot, + click, + clickText, + drag, + locate, + typeText, + press, + holdKey, + undo, + state, + select, + stop, + asset, + } = t; const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id); const graphCounts = async () => { const g = (await asset('mover')).graph; @@ -15,7 +33,10 @@ export async function graph(t) { await clickText('Mover'); await evaluate(`__gm.showTab('graph')`); await sleep(250); - check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph'); + check( + 'graph editor shows the selected behavior', + (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph', + ); await shot('16-graph'); // 13a. Drag a node: clicking its title selects it, dragging moves it (undoable). @@ -26,13 +47,24 @@ export async function graph(t) { const axisAfter = await nodeOf('n-axis'); check( 'dragging a node selects and moves it', - s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && axisAfter.x === axisBefore.x + 60 && axisAfter.y === axisBefore.y + 40, + s.selection?.kind === 'node' && + s.selection.nodeId === 'n-axis' && + axisAfter.x === axisBefore.x + 60 && + axisAfter.y === axisBefore.y + 40, `${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`, ); - check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Axis input')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`))); + check( + 'node inspector shows the node', + Boolean(await evaluate(`__gm.locate('Axis input')`)) && + Boolean(await evaluate(`__gm.locate('DATA NODE')`)), + ); await undo(); const axisUndone = await nodeOf('n-axis'); - check('undo moves the node back', axisUndone.x === axisBefore.x && axisUndone.y === axisBefore.y, `${axisUndone.x},${axisUndone.y}`); + check( + 'undo moves the node back', + axisUndone.x === axisBefore.x && axisUndone.y === axisBefore.y, + `${axisUndone.x},${axisUndone.y}`, + ); // 13b. Regression: typing a query and then *clicking* a row must add exactly one node. // (The search field's blur-commit once picked the first match and the row click picked @@ -48,7 +80,11 @@ export async function graph(t) { await click(stopRow.x, stopRow.y); await sleep(100); const afterClickPick = await graphCounts(); - check('clicking a palette row adds exactly one node', afterClickPick.nodes === beforeClickPick.nodes + 1, JSON.stringify({ beforeClickPick, afterClickPick })); + check( + 'clicking a palette row adds exactly one node', + afterClickPick.nodes === beforeClickPick.nodes + 1, + JSON.stringify({ beforeClickPick, afterClickPick }), + ); check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`))); await undo(); // so the next step finds the same empty spot and the expected graph check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes); @@ -65,7 +101,11 @@ export async function graph(t) { const after13 = await graphCounts(); s = await state(); const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null; - check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode })); + check( + 'palette adds a Log node and selects it', + after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', + JSON.stringify({ after13, newNode }), + ); await shot('17-node-added'); // 13d. Connect: drag from the Move node's exec output onto the Log node's exec input. An @@ -74,10 +114,17 @@ export async function graph(t) { const toPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false)`); await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16); const connected = await graphCounts(); - check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected)); + check( + 'dragging port to port connects the nodes', + connected.edges === before13.edges + 1, + JSON.stringify(connected), + ); const dataPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false)`); await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16); - check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit'); + check( + 'incompatible ports are rejected', + (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit', + ); await shot('18-connected'); // 13e. Delete removes the selected node together with its edge; undo brings both back. @@ -85,22 +132,37 @@ export async function graph(t) { await press('Delete'); await sleep(150); const deleted = await graphCounts(); - check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted)); + check( + 'Delete removes the node and its edge', + deleted.nodes === before13.nodes && deleted.edges === before13.edges, + JSON.stringify(deleted), + ); await undo(); const restored = await graphCounts(); - check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored)); + check( + 'undo restores node and edge', + restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, + JSON.stringify(restored), + ); // 13f. The fps readout runs in both modes. Play always starts on the Game tab; switching to // Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot. const fpsEdit = await evaluate('__gm.fps()'); check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`); // Single evaluate: the readout refreshes twice a second, so read value and label together. - check('fps label is visible in the toolbar', (await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`)) === true); + check( + 'fps label is visible in the toolbar', + (await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`)) === true, + ); await t.play(); check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game'); await sleep(600); const fpsPlay = await evaluate('__gm.fps()'); - check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`); + check( + 'fps readout keeps running in play mode', + typeof fpsPlay === 'number' && fpsPlay > 0, + `fps=${fpsPlay}`, + ); await evaluate(`__gm.showTab('graph')`); await sleep(100); const holding = holdKey('ArrowRight', 300); @@ -115,7 +177,11 @@ export async function graph(t) { await clickText('+ Variable ▾'); await clickText('Number'); await sleep(100); - check('adding a variable declares it on the behavior', (await variableKeys()) === 'speed,var1', await variableKeys()); + check( + 'adding a variable declares it on the behavior', + (await variableKeys()) === 'speed,var1', + await variableKeys(), + ); await shot('20-variable-added'); await undo(); check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys()); @@ -126,9 +192,15 @@ export async function graph(t) { check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null); await select({ kind: 'asset', assetId: 'cam' }); await sleep(100); - check('graph editor stays empty for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === null); + check( + 'graph editor stays empty for a non-behavior asset', + (await evaluate(`__gm.graph.current()`)) === null, + ); await select({ kind: 'asset', assetId: 'mover' }); await sleep(100); - check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover'); + check( + 'graph editor rebinds when a behavior is selected again', + (await evaluate(`__gm.graph.current()`)) === 'mover', + ); await evaluate(`__gm.showTab('scene')`); } diff --git a/tools/e2e/07-parenting.mjs b/tools/e2e/07-parenting.mjs index aa5cae87..a35f07d2 100644 --- a/tools/e2e/07-parenting.mjs +++ b/tools/e2e/07-parenting.mjs @@ -6,7 +6,24 @@ import { same } from '../lib/harness.mjs'; * position is kept every time. */ export async function parenting(t) { - const { check, sleep, json, shot, click, clickText, drag, locate, typeText, press, undo, mutate, select, object, entity, stop } = t; + const { + check, + sleep, + json, + shot, + click, + clickText, + drag, + locate, + typeText, + press, + undo, + mutate, + select, + object, + entity, + stop, + } = t; // 14. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world // position (local becomes the difference) and is listed right below its parent. @@ -21,11 +38,19 @@ export async function parenting(t) { await drag(blueRow.x, blueRow.y, redRow.x, redRow.y); await sleep(100); let o2 = await object('o2'); - check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2)); + check( + 'dropping a row onto another parents it, keeping the world position', + o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, + JSON.stringify(o2), + ); const order = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())'); // o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider. const o1At = order.indexOf('o1'); - check('children are listed directly below their parent', same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order)); + check( + 'children are listed directly below their parent', + same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']), + JSON.stringify(order), + ); await shot('20-parented'); // 14a. Moving the parent (X field of the red crate) drags the child along in world space. @@ -39,19 +64,31 @@ export async function parenting(t) { o2 = await object('o2'); const parent = await object('o2').then((o) => object(o.parentId)); const worldOfO2 = { x: o2.position.x + parent.position.x, y: o2.position.y + parent.position.y }; - check('moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }), `${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`); + check( + 'moving the parent moves the child in world space (local unchanged)', + o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }), + `${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`, + ); // 14b. Play: entities mirror the hierarchy; undo restores parent and position. await select(null); await press('Enter', { ctrl: true }); await sleep(300); const e2 = await entity('o2'); - check('play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2)); + check( + 'play instantiates the parent link and world positions', + e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, + JSON.stringify(e2), + ); await stop(); await undo(); // move await undo(); // parent o2 = await object('o2'); - check('undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2)); + check( + 'undo restores top level and the original local position', + o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, + JSON.stringify(o2), + ); // 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu. await clickText('Blue crate'); @@ -60,11 +97,19 @@ export async function parenting(t) { await click(menuEntry.x, menuEntry.y); await sleep(100); o2 = await object('o2'); - check('inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2)); + check( + 'inspector Parent menu parents the object', + o2.parentId === 'o1' && o2.position.x === 270, + JSON.stringify(o2), + ); const blueRow2 = await locate('Blue crate'); await click(blueRow2.x, blueRow2.y, 'right'); await clickText('Unparent (move to top level)'); await sleep(100); o2 = await object('o2'); - check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2)); + check( + 'Unparent moves the object to the top level, keeping its world position', + o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, + JSON.stringify(o2), + ); } diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs index 21ea7ff6..3c19f6c6 100644 --- a/tools/e2e/08-colliders.mjs +++ b/tools/e2e/08-colliders.mjs @@ -3,7 +3,28 @@ * behavior, and Move with "Collide with solids" – blocking, triggers, no tunnelling. */ export async function colliders(t) { - const { check, sleep, evaluate, json, shot, click, clickText, locate, press, holdKey, undo, mutate, select, state, object, objects, asset, world, entity, stop } = t; + const { + check, + sleep, + evaluate, + json, + shot, + click, + clickText, + locate, + press, + holdKey, + undo, + mutate, + select, + state, + object, + objects, + asset, + world, + entity, + stop, + } = t; // 15. Right-click the red crate → "Add collider": a collider asset sized like the crate // (100×60) is placed as its child at the centre, selected and drawn in the overlay. The @@ -17,13 +38,26 @@ export async function colliders(t) { const s = await state(); const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const colAsset = colObj ? await asset(colObj.assetId) : null; - check('Add collider creates a collider child sized like the sprite', colObj?.parentId === 'o1' && colAsset?.type === 'collider' && colAsset.props.width === 100 && colAsset.props.height === 60, JSON.stringify({ colObj, colAsset })); - check('collider is drawn in the overlay layer', colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay'); + check( + 'Add collider creates a collider child sized like the sprite', + colObj?.parentId === 'o1' && + colAsset?.type === 'collider' && + colAsset.props.width === 100 && + colAsset.props.height === 60, + JSON.stringify({ colObj, colAsset }), + ); + check( + 'collider is drawn in the overlay layer', + colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay', + ); check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box ▾')`))); await shot('21-collider'); await undo(); const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false; - check('undo removes the added collider object and its asset', (await objects()).length === objectsBefore && colliderAssetGone); + check( + 'undo removes the added collider object and its asset', + (await objects()).length === objectsBefore && colliderAssetGone, + ); // 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with // the crates overlapping: the log names the blue crate exactly once (enter, not stay), @@ -39,10 +73,20 @@ export async function colliders(t) { await press('Enter', { ctrl: true }); await sleep(500); const collisionWorld = await world(); - check('On collision enter fired once with the other body', collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, JSON.stringify(collisionWorld.log)); - const spriteCount = await evaluate(`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`); + check( + 'On collision enter fired once with the other body', + collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, + JSON.stringify(collisionWorld.log), + ); + const spriteCount = await evaluate( + `__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`, + ); const info = await json('__gm.gameInfo()'); - check('colliders are not rendered in the game view', info.visibleItems === spriteCount, `${JSON.stringify(info)} sprites=${spriteCount}`); + check( + 'colliders are not rendered in the game view', + info.visibleItems === spriteCount, + `${JSON.stringify(info)} sprites=${spriteCount}`, + ); await stop(); // 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the @@ -65,11 +109,23 @@ export async function colliders(t) { return red; }; const blocked = await playAgainstBlue(false); - check("Move with \"Collide with solids\" stops the red crate flush against the blue crate's solid collider", blocked && Math.abs(blocked.x - -120) < 0.01, `world x=${blocked?.x}`); + check( + 'Move with "Collide with solids" stops the red crate flush against the blue crate\'s solid collider', + blocked && Math.abs(blocked.x - -120) < 0.01, + `world x=${blocked?.x}`, + ); const passed = await playAgainstBlue(true); - check('a trigger collider does not block (the crate passes through)', passed && passed.x > -120 + 20, `world x=${passed?.x}`); + check( + 'a trigger collider does not block (the crate passes through)', + passed && passed.x > -120 + 20, + `world x=${passed?.x}`, + ); const fast = await playAgainstBlue(false, 40000); - check('a very fast mover cannot tunnel through a solid collider', fast && Math.abs(fast.x - -120) < 0.5, `world x=${fast?.x}`); + check( + 'a very fast mover cannot tunnel through a solid collider', + fast && Math.abs(fast.x - -120) < 0.5, + `world x=${fast?.x}`, + ); await mutate(`__gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200`); // later steps expect the seeded speed await select({ kind: 'object', objectId: 'oc2' }); await sleep(200); diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs index e5bc6c16..6e520d79 100644 --- a/tools/e2e/09-containers.mjs +++ b/tools/e2e/09-containers.mjs @@ -6,7 +6,28 @@ import { same } from '../lib/harness.mjs'; * instance values against the template and the asset. */ export async function containers(t) { - const { check, sleep, evaluate, json, shot, click, clickText, drag, locate, press, undo, mutate, select, state, object, objects, asset, world, play, stop } = t; + const { + check, + sleep, + evaluate, + json, + shot, + click, + clickText, + drag, + locate, + press, + undo, + mutate, + select, + state, + object, + objects, + asset, + world, + play, + stop, + } = t; const center = await json('__gm.viewportCenter()'); const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`); @@ -23,12 +44,22 @@ export async function containers(t) { const blueRow = await locate('Blue crate'); await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); let s = await state(); - check('Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1', 'o2']) && s.selection.objectId === 'o2', JSON.stringify(s.selection)); + check( + 'Ctrl-click in the hierarchy adds to the selection', + s.selection?.kind === 'object' && + same(s.selection.objectIds, ['o1', 'o2']) && + s.selection.objectId === 'o2', + JSON.stringify(s.selection), + ); check('inspector shows the multi-selection', await has('2 OBJECTS SELECTED')); await shot('23-multi-selected'); await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); s = await state(); - check('Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']), JSON.stringify(s.selection)); + check( + 'Ctrl-click again removes the object from the selection', + s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']), + JSON.stringify(s.selection), + ); await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); // 16a. Create container: both crates (with their colliders and the Mover) become the content @@ -44,26 +75,48 @@ export async function containers(t) { const containerAsset = containerObj ? await asset(containerObj.assetId) : null; check( 'Create container adds one container asset and one placed container, selected', - containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await json('__gm.state.project.assets')).length === assetsBefore + 1 && (await objects()).length === objectsBefore + 1, + containerAsset?.type === 'container' && + s.selection.objectIds.length === 1 && + (await json('__gm.state.project.assets')).length === assetsBefore + 1 && + (await objects()).length === objectsBefore + 1, JSON.stringify({ containerObj, type: containerAsset?.type }), ); - check('the container sits at the centre of the selection', containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }), JSON.stringify(containerObj?.position)); + check( + 'the container sits at the centre of the selection', + containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }), + JSON.stringify(containerObj?.position), + ); let o1c = await object('o1'); const o2c = await object('o2'); check( 'the crates are re-parented below the container with local positions relative to its centre', - o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, + o1c.parentId === containerObj?.id && + o1c.position.x === -135 && + o1c.position.y === 40 && + o2c.parentId === containerObj?.id && + o2c.position.x === 135 && + o2c.position.y === -40, JSON.stringify({ o1: o1c.position, o2: o2c.position }), ); check( 'the template holds the whole subtree (2 crates, 2 colliders, the Mover)', - containerAsset?.objects.length === 5 && containerAsset.objects.filter((o) => o.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((o) => o.id === o1c.templateId), + containerAsset?.objects.length === 5 && + containerAsset.objects.filter((o) => o.parentId === '').length === 2 && + o1c.templateId && + containerAsset.objects.some((o) => o.id === o1c.templateId), JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])), ); const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())'); const cAt = orderC.indexOf(containerObj?.id); - check('hierarchy lists the container with its content indented below', cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC)); - check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay'); + check( + 'hierarchy lists the container with its content indented below', + cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), + JSON.stringify(orderC), + ); + check( + 'container symbol is drawn in the overlay layer', + containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay', + ); await shot('24-container-created'); // 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it. @@ -71,7 +124,11 @@ export async function containers(t) { await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y); const containerMoved = await object(containerObj.id); o1c = await object('o1'); - check('dragging the container moves the group (children keep their local position)', containerMoved.position.x === 85 && o1c.position.x === -135, JSON.stringify({ container: containerMoved.position, o1: o1c.position })); + check( + 'dragging the container moves the group (children keep their local position)', + containerMoved.position.x === 85 && o1c.position.x === -135, + JSON.stringify({ container: containerMoved.position, o1: o1c.position }), + ); await undo(); check('undo moves the container back', (await object(containerObj.id)).position.x === -15); @@ -80,7 +137,11 @@ export async function containers(t) { // (at y = 110), not the camera (at the origin). await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50); s = await state(); - check('rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']), JSON.stringify(s.selection)); + check( + 'rubber-band selection picks the objects inside it', + s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']), + JSON.stringify(s.selection), + ); await shot('25-marquee'); // 16d. Dragging one of several selected objects moves all of them as one undo step. @@ -89,7 +150,11 @@ export async function containers(t) { await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20); o1c = await object('o1'); const o2d = await object('o2'); - check('dragging a multi-selection moves every selected object', o1c.position.x === -85 && o2d.position.x === 185, JSON.stringify({ o1: o1c.position, o2: o2d.position })); + check( + 'dragging a multi-selection moves every selected object', + o1c.position.x === -85 && o2d.position.x === 185, + JSON.stringify({ o1: o1c.position, o2: o2d.position }), + ); await undo(); o1c = await object('o1'); check('one undo restores both', o1c.position.x === -135 && (await object('o2')).position.x === 135); @@ -99,7 +164,10 @@ export async function containers(t) { await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); await press('Delete'); await sleep(150); - check('Delete removes the whole multi-selection with its subtrees', (await objects()).length === objectsBeforeDelete - 5); + check( + 'Delete removes the whole multi-selection with its subtrees', + (await objects()).length === objectsBeforeDelete - 5, + ); await undo(); check('undo restores all of them', (await objects()).length === objectsBeforeDelete); @@ -110,14 +178,25 @@ export async function containers(t) { await sleep(100); const afterPlace = await objects(); const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id); - const secondCrate = secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId); - check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate })); + const secondCrate = + secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId); + check( + 'placing the container asset instantiates its content below a new container object', + afterPlace.length === objectsBeforeDelete + 6 && + secondCrate && + secondCrate.position.x === -135 && + secondCrate.position.y === 40, + JSON.stringify({ count: afterPlace.length, secondCrate }), + ); await shot('26-container-placed'); // 16f-ii. Spawning the container at runtime instantiates its content the same way: point the // Spawner at the container asset, press Space while playing – a whole instance appears // in the runtime copy and in the world, and Stop throws it away with the rest. - const spawnParam = (assetId) => mutate(`__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`); + const spawnParam = (assetId) => + mutate( + `__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`, + ); await spawnParam(containerAsset.id); const beforeSpawn = await objects(); await play(); @@ -127,11 +206,17 @@ export async function containers(t) { const spawnedRoot = fresh.find((o) => o.parentId === ''); check( 'Spawn of a container asset creates the container object plus its content', - fresh.length === 6 && spawnedRoot?.assetId === containerAsset.id && fresh.filter((o) => o.templateId).length === 5, + fresh.length === 6 && + spawnedRoot?.assetId === containerAsset.id && + fresh.filter((o) => o.templateId).length === 5, JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))), ); const spawnWorld = await world(); - check('the spawned instance lives in the world and its name reached the Log node', spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) })); + check( + 'the spawned instance lives in the world and its name reached the Log node', + spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name), + JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) }), + ); await stop(); check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length); await spawnParam('crate'); @@ -151,10 +236,17 @@ export async function containers(t) { await shot('27-apply-buttons'); await click(applyButton.x, applyButton.y); await sleep(100); - check('apply writes x into the template and the unmodified copy follows', (await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100, JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position })); + check( + 'apply writes x into the template and the unmodified copy follows', + (await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100, + JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position }), + ); check('apply hides the buttons again', !(await has('↑'))); await undo(); - check('undo restores template and copy', (await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135); + check( + 'undo restores template and copy', + (await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135, + ); const revertButton = await evaluate(`__gm.locate('↺')`); await click(revertButton.x, revertButton.y); await sleep(100); @@ -165,41 +257,72 @@ export async function containers(t) { const applyMenu = await evaluate(`__gm.locate('↑ ▾')`); check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu)); await click(applyMenu.x, applyMenu.y); - check('the menu offers container and asset', (await has('Apply to container')) && (await has('Apply to asset'))); + check( + 'the menu offers container and asset', + (await has('Apply to container')) && (await has('Apply to asset')), + ); await shot('28-apply-menu'); await clickText('Apply to asset'); await sleep(100); const crateAfterApply = (await asset('crate')).props; - check('Apply to asset writes the value into the asset and drops the override', crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined, JSON.stringify(crateAfterApply)); + check( + 'Apply to asset writes the value into the asset and drops the override', + crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined, + JSON.stringify(crateAfterApply), + ); await undo(); - check('undo restores asset and override', (await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77); + check( + 'undo restores asset and override', + (await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77, + ); await click(applyMenu.x, applyMenu.y); await clickText('Apply to container'); await sleep(100); - check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77); + check( + 'Apply to container writes the override into the template and the copy', + (await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77, + ); // 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases // the value really differs from are offered, so "Reset to container" is left out and the // menu collapses to a direct ↺. Deviating from both brings the menu back. await sleep(200); - check('a reset that would change nothing is left out – a direct button instead of a menu', (await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾'))); + check( + 'a reset that would change nothing is left out – a direct button instead of a menu', + (await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾')), + ); await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55`); const resetMenu = await evaluate(`__gm.locate('↺ ▾')`); check('deviating from template and asset brings the reset menu back', Boolean(resetMenu)); await click(resetMenu.x, resetMenu.y); - check('the reset menu offers container and asset', (await has('Reset to container')) && (await has('Reset to asset'))); + check( + 'the reset menu offers container and asset', + (await has('Reset to container')) && (await has('Reset to asset')), + ); await shot('28b-reset-menu'); await clickText('Reset to container'); await sleep(100); - check('Reset to container puts the override back on the template value', (await object('o1')).overrides.width === 77); + check( + 'Reset to container puts the override back on the template value', + (await object('o1')).overrides.width === 77, + ); const singleReset = await evaluate(`__gm.locate('↺')`); - check('with only the asset left to differ from, the menu is a single button again', Boolean(singleReset) && !(await has('↺ ▾'))); + check( + 'with only the asset left to differ from, the menu is a single button again', + Boolean(singleReset) && !(await has('↺ ▾')), + ); await click(singleReset.x, singleReset.y); await sleep(100); - check('↺ drops the override so the object follows the asset again', (await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77); + check( + '↺ drops the override so the object follows the asset again', + (await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77, + ); // Dropping the override left the object on the asset value (100) while the template still // says 77 – that is a real deviation, so exactly one entry remains: "Reset to container". - check('the remaining deviation from the template keeps a single button, not a menu', (await has('↺')) && !(await has('↺ ▾'))); + check( + 'the remaining deviation from the template keeps a single button, not a menu', + (await has('↺')) && !(await has('↺ ▾')), + ); await undo(); check('undo restores the override', (await object('o1')).overrides.width === 77); } diff --git a/tools/e2e/10-text-and-enabled.mjs b/tools/e2e/10-text-and-enabled.mjs index c6a4de7f..6d050999 100644 --- a/tools/e2e/10-text-and-enabled.mjs +++ b/tools/e2e/10-text-and-enabled.mjs @@ -29,10 +29,21 @@ export async function textAndEnabled(t) { await click(enabledRow.x + 55, enabledRow.y); await sleep(100); const textObject = await object('otext'); - check('clicking it writes an override instead of changing the asset', textObject.overrides.enabled === false, JSON.stringify(textObject.overrides)); - check('a switched-off object is not drawn in the scene view', !(await json('__gm.drawOrder()')).includes('otext')); + check( + 'clicking it writes an override instead of changing the asset', + textObject.overrides.enabled === false, + JSON.stringify(textObject.overrides), + ); + check( + 'a switched-off object is not drawn in the scene view', + !(await json('__gm.drawOrder()')).includes('otext'), + ); const visibleAfter = (await json('__gm.viewportInfo()')).visibleItems; - check('the scene view renders one item less', visibleAfter === visibleBefore - 1, `${visibleBefore} → ${visibleAfter}`); + check( + 'the scene view renders one item less', + visibleAfter === visibleBefore - 1, + `${visibleBefore} → ${visibleAfter}`, + ); check('it stays selectable in the hierarchy, so its switch stays reachable', await has('Enabled')); await shot('30-disabled'); @@ -44,7 +55,11 @@ export async function textAndEnabled(t) { await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true`); await sleep(200); const onItems = (await json('__gm.gameInfo()')).visibleItems; - check('the game renders one item more once the object is switched on', onItems === offItems + 1, `${offItems} → ${onItems}`); + check( + 'the game renders one item more once the object is switched on', + onItems === offItems + 1, + `${offItems} → ${onItems}`, + ); await stop(); check('stop leaves the play-mode switch behind', (await object('otext')).overrides.enabled === false); } diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 25bf0683..37c70698 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -14,4 +14,15 @@ import { colliders } from './08-colliders.mjs'; import { containers } from './09-containers.mjs'; import { textAndEnabled } from './10-text-and-enabled.mjs'; -export const STEPS = [hierarchy, inspector, play, assets, drawing, graph, parenting, colliders, containers, textAndEnabled]; +export const STEPS = [ + hierarchy, + inspector, + play, + assets, + drawing, + graph, + parenting, + colliders, + containers, + textAndEnabled, +]; diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs index 60903ac5..2e1d842e 100644 --- a/tools/lib/builders.mjs +++ b/tools/lib/builders.mjs @@ -4,7 +4,11 @@ */ /** A scene object (also a container template entry). Position defaults to the origin. */ -export const sceneObject = (id, assetId, { name = '', x = 0, y = 0, z = 0, overrides = {}, parentId = '', templateId = '', editorOffset } = {}) => ({ +export const sceneObject = ( + id, + assetId, + { name = '', x = 0, y = 0, z = 0, overrides = {}, parentId = '', templateId = '', editorOffset } = {}, +) => ({ id, assetId, name, @@ -16,7 +20,11 @@ export const sceneObject = (id, assetId, { name = '', x = 0, y = 0, z = 0, overr }); /** A graph edge from `from.fromPort` to `to.toPort`. */ -export const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); +export const edge = (id, from, fromPort, to, toPort) => ({ + id, + from: { node: from, port: fromPort }, + to: { node: to, port: toPort }, +}); /** A graph node; `params` holds settings and inline input values alike. */ export const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params }); @@ -41,10 +49,14 @@ export function containerFactory(templates) { for (const t of template) { const id = `${hostId}-${t.id}`; objects.push({ ...t, id, parentId: t.parentId ? `${hostId}-${t.parentId}` : hostId, templateId: t.id }); - if (templates[t.assetId] && !template.some((o) => o.parentId === t.id)) objects.push(...expand(t.assetId, id)); + if (templates[t.assetId] && !template.some((o) => o.parentId === t.id)) + objects.push(...expand(t.assetId, id)); } return objects; }; - const instantiate = (id, assetId, options = {}) => [sceneObject(id, assetId, options), ...expand(assetId, id)]; + const instantiate = (id, assetId, options = {}) => [ + sceneObject(id, assetId, options), + ...expand(assetId, id), + ]; return { expand, instantiate }; } diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index e8e18a9b..a6382fc9 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -67,7 +67,9 @@ async function waitForChrome(port) { try { const res = await fetch(`http://127.0.0.1:${port}/json/version`); if (res.ok) return res.json(); - } catch {} + } catch { + // Chromium has not opened the debugging port yet – retry. + } await sleep(200); } throw new Error('chromium did not start'); @@ -75,7 +77,9 @@ async function waitForChrome(port) { /** The toolbox handed to a check body – everything a step needs to drive and inspect the editor. */ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { - const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value; + const evaluate = async (expression) => + (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result + .value; /** Evaluates an expression in the page and returns it as plain JSON; `undefined` comes back as null. */ const json = async (expression) => JSON.parse(await evaluate(`JSON.stringify((${expression}) ?? null)`)); const locate = async (text, last = false) => { @@ -91,7 +95,14 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { // --- mouse --------------------------------------------------------------------------------- const mouse = (type, x, y, button = 'left', extra = {}) => - cdp.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, ...extra }); + cdp.send('Input.dispatchMouseEvent', { + type, + x, + y, + button, + clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, + ...extra, + }); const click = async (x, y, button = 'left', modifiers = {}) => { const extra = { modifiers: modifierBits(modifiers) }; await mouse('mouseMoved', x, y); @@ -107,7 +118,9 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await mouse('mouseMoved', x1, y1); await mouse('mousePressed', x1, y1); for (let i = 1; i <= steps; i++) { - await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { buttons: 1 }); + await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { + buttons: 1, + }); await sleep(16); } await mouse('mouseReleased', x2, y2); @@ -147,7 +160,10 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { // --- editor state -------------------------------------------------------------------------- /** Selection, mode, save status and notice – the facts most checks look at. */ - const state = () => json('{ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice }'); + const state = () => + json( + '{ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice }', + ); const objects = () => json('__gm.state.activeScene.objects'); const object = (id) => json(`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(id)})`); const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})`); @@ -185,21 +201,59 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { }; return { - cdp, projectId, baseUrl, consoleLog, exceptions, check, sleep, - evaluate, json, locate, shot, - mouse, click, clickText, drag, - press, holdKey, typeText, undo, redo, - state, objects, object, asset, world, entity, select, mutate, play, stop, - api, sceneOf, navigate, + cdp, + projectId, + baseUrl, + consoleLog, + exceptions, + check, + sleep, + evaluate, + json, + locate, + shot, + mouse, + click, + clickText, + drag, + press, + holdKey, + typeText, + undo, + redo, + state, + objects, + object, + asset, + world, + entity, + select, + mutate, + play, + stop, + api, + sceneOf, + navigate, }; } export async function runChecks(options, body) { const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options; - const chrome = spawn('chromium', [ - '--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', - `--remote-debugging-port=${port}`, `--window-size=${width},${height}`, '--hide-scrollbars', 'about:blank', - ], { stdio: 'ignore' }); + const chrome = spawn( + 'chromium', + [ + '--headless', + '--no-sandbox', + '--disable-gpu', + '--use-gl=swiftshader', + '--enable-unsafe-swiftshader', + `--remote-debugging-port=${port}`, + `--window-size=${width},${height}`, + '--hide-scrollbars', + 'about:blank', + ], + { stdio: 'ignore' }, + ); const consoleLog = []; let failures = 0; @@ -210,16 +264,28 @@ export async function runChecks(options, body) { try { await waitForChrome(port); - const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json(); + const target = await ( + await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' }) + ).json(); const ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((r) => ws.addEventListener('open', r)); const cdp = new CDP(ws); await cdp.send('Page.enable'); await cdp.send('Runtime.enable'); - await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }); + await cdp.send('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: 1, + mobile: false, + }); cdp.on((msg) => { - if (msg.method === 'Runtime.consoleAPICalled') consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' ')); - if (msg.method === 'Runtime.exceptionThrown') consoleLog.push('EXCEPTION ' + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text)); + if (msg.method === 'Runtime.consoleAPICalled') + consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' ')); + if (msg.method === 'Runtime.exceptionThrown') + consoleLog.push( + 'EXCEPTION ' + + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text), + ); }); await body(makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix })); ws.close(); @@ -230,7 +296,10 @@ export async function runChecks(options, body) { await new Promise((resolve) => { if (chrome.exitCode !== null) return resolve(); const timer = setTimeout(() => chrome.kill('SIGKILL'), 5000); - chrome.once('exit', () => { clearTimeout(timer); resolve(); }); + chrome.once('exit', () => { + clearTimeout(timer); + resolve(); + }); chrome.kill('SIGTERM'); }); } diff --git a/tools/lib/projects.mjs b/tools/lib/projects.mjs index a8ad69c8..187d34cd 100644 --- a/tools/lib/projects.mjs +++ b/tools/lib/projects.mjs @@ -39,8 +39,15 @@ export async function writeProject(projectId, baseUrl, project) { process.exit(1); } const doc = await current.json(); - const body = { revision: doc.revision, project: { ...project, id: projectId, name: doc.name ?? project.name } }; - const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const body = { + revision: doc.revision, + project: { ...project, id: projectId, name: doc.name ?? project.name }, + }; + const res = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); const result = await res.json(); if (!res.ok) { console.error('PUT failed', res.status, result); diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index 55db6731..000b1001 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -61,7 +61,12 @@ const project = { props: { backgroundColor: 0x22252c }, objects: [ sceneObject('o1', 'crate', { x: -150 }), - sceneObject('o2', 'crate', { name: 'Blue crate', x: 120, y: -80, overrides: { width: 140, color: 0x4488ff } }), + sceneObject('o2', 'crate', { + name: 'Blue crate', + x: 120, + y: -80, + overrides: { width: 140, color: 0x4488ff }, + }), sceneObject('o3', 'cam'), // The Mover is parented to the red crate (its owner); its position is local to it. sceneObject('o4', 'mover', { y: 110, parentId: 'o1' }), @@ -75,8 +80,20 @@ const project = { }, { id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } }, { id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } }, - { id: 'collider', type: 'collider', name: 'Crate collider', props: { shape: 'box', width: 100, height: 60, radius: 50, isTrigger: false } }, - { id: 'mover', type: 'behavior', name: 'Mover', props: { speed: 200 }, variables: [variable('speed', 'number', 0)], graph: moverGraph }, + { + id: 'collider', + type: 'collider', + name: 'Crate collider', + props: { shape: 'box', width: 100, height: 60, radius: 50, isTrigger: false }, + }, + { + id: 'mover', + type: 'behavior', + name: 'Mover', + props: { speed: 200 }, + variables: [variable('speed', 'number', 0)], + graph: moverGraph, + }, { id: 'spawner', type: 'behavior', name: 'Spawner', props: {}, variables: [], graph: spawnerGraph }, ], }; diff --git a/tools/seed-jumprun.mjs b/tools/seed-jumprun.mjs index c6517740..4a199174 100644 --- a/tools/seed-jumprun.mjs +++ b/tools/seed-jumprun.mjs @@ -40,8 +40,21 @@ const solid = (id, x, y, w, h, color) => [ sceneObject(`${id}-c`, 'solid', { parentId: id, overrides: { width: w, height: h } }), ]; -const text = (id, x, y, content, { size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {}) => - sceneObject(id, 'label', { name, x, y, z, parentId, overrides: { text: content, fontSize: size, color, align } }); +const text = ( + id, + x, + y, + content, + { size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {}, +) => + sceneObject(id, 'label', { + name, + x, + y, + z, + parentId, + overrides: { text: content, fontSize: size, color, align }, + }); /** One placed coin: the container object; its content comes from the coin template. */ const coin = (id, x, y) => sceneObject(id, 'coin', { name: 'Coin', x, y }); @@ -88,8 +101,16 @@ const screenTemplate = (prefix, headline, hint, buttonLabel, behaviorId) => [ sceneObject(`${prefix}-panel`, 'box', { overrides: { width: 640, height: 360, color: PANEL } }), text(`${prefix}-title`, 0, -70, headline, { size: 52 }), text(`${prefix}-hint`, 0, -10, hint, { size: 20, color: HINT }), - sceneObject(`${prefix}-button`, 'box', { y: 80, z: 1, overrides: { width: 240, height: 60, color: BUTTON } }), - text(`${prefix}-button-label`, 0, 0, buttonLabel, { size: 26, color: BUTTON_TEXT, parentId: `${prefix}-button` }), + sceneObject(`${prefix}-button`, 'box', { + y: 80, + z: 1, + overrides: { width: 240, height: 60, color: BUTTON }, + }), + text(`${prefix}-button-label`, 0, 0, buttonLabel, { + size: 26, + color: BUTTON_TEXT, + parentId: `${prefix}-button`, + }), sceneObject(`${prefix}-button-logic`, behaviorId, { parentId: `${prefix}-button`, y: -60 }), ]; @@ -350,20 +371,55 @@ const project = { ...instantiate('L', 'level', { name: 'Level' }), // All three screens sit on the camera frame; the Game behavior decides on `On start` which // one is up. Editor offsets pull them apart in the scene view so they do not cover the level. - ...instantiate('T', 'title', { name: 'Title', z: 10, overrides: { enabled: true }, editorOffset: { x: -750, y: -500 } }), - ...instantiate('W', 'win', { name: 'Win', z: 10, overrides: { enabled: true }, editorOffset: { x: 0, y: -500 } }), - ...instantiate('B', 'lose', { name: 'Lose', z: 10, overrides: { enabled: true }, editorOffset: { x: 750, y: -500 } }), + ...instantiate('T', 'title', { + name: 'Title', + z: 10, + overrides: { enabled: true }, + editorOffset: { x: -750, y: -500 }, + }), + ...instantiate('W', 'win', { + name: 'Win', + z: 10, + overrides: { enabled: true }, + editorOffset: { x: 0, y: -500 }, + }), + ...instantiate('B', 'lose', { + name: 'Lose', + z: 10, + overrides: { enabled: true }, + editorOffset: { x: 750, y: -500 }, + }), sceneObject('ocam', 'cam', { name: 'Main Camera' }), sceneObject('ogame', 'game', { name: 'Game', x: -200, y: -220 }), ], }, { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } }, { id: 'box', type: 'sprite', name: 'Box', props: { width: 100, height: 100, color: STONE } }, - { id: 'player-sprite', type: 'sprite', name: 'Player Sprite', props: { width: 30, height: 40, color: PLAYER } }, + { + id: 'player-sprite', + type: 'sprite', + name: 'Player Sprite', + props: { width: 30, height: 40, color: PLAYER }, + }, { id: 'coin-sprite', type: 'sprite', name: 'Coin Sprite', props: { width: 24, height: 24, color: COIN } }, - { id: 'label', type: 'text', name: 'Label', props: { text: 'Text', fontSize: 28, color: WHITE, align: 'center' } }, - { id: 'solid', type: 'collider', name: 'Solid Collider', props: { shape: 'box', width: 100, height: 100, radius: 50, isTrigger: false } }, - { id: 'trigger', type: 'collider', name: 'Coin Trigger', props: { shape: 'box', width: 24, height: 24, radius: 12, isTrigger: true } }, + { + id: 'label', + type: 'text', + name: 'Label', + props: { text: 'Text', fontSize: 28, color: WHITE, align: 'center' }, + }, + { + id: 'solid', + type: 'collider', + name: 'Solid Collider', + props: { shape: 'box', width: 100, height: 100, radius: 50, isTrigger: false }, + }, + { + id: 'trigger', + type: 'collider', + name: 'Coin Trigger', + props: { shape: 'box', width: 24, height: 24, radius: 12, isTrigger: true }, + }, { id: 'player', type: 'behavior', @@ -391,8 +447,22 @@ const project = { ], graph: gameGraph, }, - { id: 'start-button', type: 'behavior', name: 'Start Button', props: {}, variables: [], graph: startButtonGraph }, - { id: 'restart-button', type: 'behavior', name: 'Restart Button', props: {}, variables: [], graph: restartButtonGraph }, + { + id: 'start-button', + type: 'behavior', + name: 'Start Button', + props: {}, + variables: [], + graph: startButtonGraph, + }, + { + id: 'restart-button', + type: 'behavior', + name: 'Restart Button', + props: {}, + variables: [], + graph: restartButtonGraph, + }, { id: 'coin', type: 'container', name: 'Coin', props: {}, objects: coinTemplate }, { id: 'level', type: 'container', name: 'Level', props: {}, objects: levelTemplate }, { id: 'title', type: 'container', name: 'Title screen', props: {}, objects: templates.title }, diff --git a/tools/smoke-jumprun.mjs b/tools/smoke-jumprun.mjs index 284c4682..e9d58fa4 100644 --- a/tools/smoke-jumprun.mjs +++ b/tools/smoke-jumprun.mjs @@ -18,7 +18,23 @@ const GRAPHS = { player: [35, 38], game: [38, 43], 'start-button': [9, 10], 'res const GROUND_Y = 130; await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (t) => { - const { check, sleep, evaluate, json, shot, click, holdKey, press, entity, world, mutate, play, stop, navigate, exceptions } = t; + const { + check, + sleep, + evaluate, + json, + shot, + click, + holdKey, + press, + entity, + world, + mutate, + play, + stop, + navigate, + exceptions, + } = t; /** Variables of the "Game" behavior instance, live from the running world. */ const gameVars = async () => (await entity('ogame')).props; @@ -32,15 +48,20 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async ( }; /** Edits the play copy (never the saved project) and pushes it into the running world. */ const setVariable = (objectId, key, value) => - mutate(`__gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}).overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`); + mutate( + `__gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}).overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`, + ); await navigate(); // 1. Nothing was dropped by the loader: every node and edge of the four behaviors survived. - const graphs = await json(`__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => ({ id: a.id, n: a.graph.nodes.length, e: a.graph.edges.length }))`); + const graphs = await json( + `__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => ({ id: a.id, n: a.graph.nodes.length, e: a.graph.edges.length }))`, + ); check( 'all behavior graphs load complete (no unknown node types, no dropped edges)', - graphs.length === 4 && graphs.every((g) => GRAPHS[g.id] && g.n === GRAPHS[g.id][0] && g.e === GRAPHS[g.id][1]), + graphs.length === 4 && + graphs.every((g) => GRAPHS[g.id] && g.n === GRAPHS[g.id][0] && g.e === GRAPHS[g.id][1]), JSON.stringify(graphs), ); const objectCount = await evaluate('__gm.state.activeScene.objects.length'); @@ -54,39 +75,70 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async ( check('the title screen is up', await visible('JUMP & RUN')); const level = await entity('L'); const title = await entity('T'); - check('the Game behavior switched the level off and the title on', level.props.enabled === false && title.props.enabled === true, JSON.stringify({ level: level.props.enabled, title: title.props.enabled })); + check( + 'the Game behavior switched the level off and the title on', + level.props.enabled === false && title.props.enabled === true, + JSON.stringify({ level: level.props.enabled, title: title.props.enabled }), + ); await shot('02-title'); // 3. The Start button is a click target in the running game (On click + hit test). await clickObject('T-title-button'); await sleep(400); - check('clicking Start hides the title and shows the level', (await entity('L')).props.enabled === true && (await entity('T')).props.enabled === false); + check( + 'clicking Start hides the title and shows the level', + (await entity('L')).props.enabled === true && (await entity('T')).props.enabled === false, + ); const vars = await gameVars(); - check('the run starts with a full clock and no coins', vars.running === true && vars.coins === 0 && vars.timeLeft > 28, JSON.stringify(vars)); + check( + 'the run starts with a full clock and no coins', + vars.running === true && vars.coins === 0 && vars.timeLeft > 28, + JSON.stringify(vars), + ); await shot('03-running'); // 4. Gravity: the player rests on the ground (its 40-high box on the ground's top edge, y = 150). const grounded = await entity('L-player'); - check('gravity dropped the player onto the ground (top edge at y = 150)', Math.abs(grounded.worldY - GROUND_Y) < 1.5, `world y ${grounded.worldY.toFixed(2)}`); + check( + 'gravity dropped the player onto the ground (top edge at y = 150)', + Math.abs(grounded.worldY - GROUND_Y) < 1.5, + `world y ${grounded.worldY.toFixed(2)}`, + ); // 5. Jumping: Space lifts the player (y gets smaller) and gravity brings it back down. await press('Space'); await sleep(200); const airborne = await entity('L-player'); - check('Space makes the player jump', airborne.worldY < grounded.worldY - 25, `y ${grounded.worldY.toFixed(1)} → ${airborne.worldY.toFixed(1)}`); + check( + 'Space makes the player jump', + airborne.worldY < grounded.worldY - 25, + `y ${grounded.worldY.toFixed(1)} → ${airborne.worldY.toFixed(1)}`, + ); await sleep(1200); const landed = await entity('L-player'); - check('the player falls back onto the ground', Math.abs(landed.worldY - GROUND_Y) < 1.5, `y ${landed.worldY.toFixed(2)}`); + check( + 'the player falls back onto the ground', + Math.abs(landed.worldY - GROUND_Y) < 1.5, + `y ${landed.worldY.toFixed(2)}`, + ); await shot('04-jumped'); // 6. Walking and collecting: running into a coin removes it and counts up. const coinsBefore = await coins(); await holdKey('ArrowRight', 1500); const walked = await entity('L-player'); - check('arrow keys walk the player', walked.worldX > landed.worldX + 40, `x ${landed.worldX.toFixed(1)} → ${walked.worldX.toFixed(1)}`); + check( + 'arrow keys walk the player', + walked.worldX > landed.worldX + 40, + `x ${landed.worldX.toFixed(1)} → ${walked.worldX.toFixed(1)}`, + ); const afterCoin = await gameVars(); const coinsAfter = await coins(); - check('running into a coin collects it', afterCoin.coins >= 1 && coinsAfter < coinsBefore, JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter })); + check( + 'running into a coin collects it', + afterCoin.coins >= 1 && coinsAfter < coinsBefore, + JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter }), + ); check('the HUD shows the counter', await visible('Coins 1/10')); check('the clock is counting down', afterCoin.timeLeft < 28, `timeLeft ${afterCoin.timeLeft}`); await shot('05-collected'); @@ -94,13 +146,21 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async ( // 7. Winning: with all ten coins the Game behavior swaps the level for the win screen. await setVariable('ogame', 'coins', 10); await sleep(300); - check('collecting everything wins the game', (await visible('YOU WIN!')) && (await entity('W')).props.enabled === true && (await entity('L')).props.enabled === false); + check( + 'collecting everything wins the game', + (await visible('YOU WIN!')) && + (await entity('W')).props.enabled === true && + (await entity('L')).props.enabled === false, + ); await shot('06-won'); // 8. Restart game: back to a fresh run at the title screen, coins and clock reset. await clickObject('W-win-button'); await sleep(1200); - check('Play again restarts the scene at the title screen', (await visible('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play'); + check( + 'Play again restarts the scene at the title screen', + (await visible('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play', + ); check('the restarted run has all ten coins back', (await coins()) === 10, `${await coins()} coins`); // 9. Losing: start again, run the clock out. @@ -108,13 +168,22 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async ( await sleep(300); await setVariable('ogame', 'timeLeft', 0.2); await sleep(600); - check('the clock running out loses the game', (await visible('TIME UP')) && (await entity('B')).props.enabled === true); + check( + 'the clock running out loses the game', + (await visible('TIME UP')) && (await entity('B')).props.enabled === true, + ); await shot('07-lost'); // 10. Stop throws the run away – the saved project is untouched. await stop(); await sleep(200); - const saved = await json('{ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() }'); - check('stop restores the edited scene', saved.objects === OBJECT_COUNT && saved.mode === 'edit' && saved.tab === 'scene', JSON.stringify(saved)); + const saved = await json( + '{ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() }', + ); + check( + 'stop restores the edited scene', + saved.objects === OBJECT_COUNT && saved.mode === 'edit' && saved.tab === 'scene', + JSON.stringify(saved), + ); check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | ')); });