diff --git a/.eslintrc.json b/.eslintrc.json index b99e945..6c8e9d3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,13 +3,38 @@ "ecmaVersion": 9, "sourceType": "module" }, - "plugins": ["prettier"], - "extends": ["eslint:recommended", "plugin:prettier/recommended"], + "plugins": ["@typescript-eslint", "prettier"], + "extends": [ + "eslint:recommended", + "plugin:prettier/recommended" + ], "env": { "browser": true, "es6": true }, "rules": { "no-console": ["warn", {"allow": ["warn", "error"]}] - } + }, + "overrides": [ + { + "files": ["src/**/*"], + "parserOptions": { + "parser": "@typescript-eslint/parser", + "project": "./tsconfig.json" + }, + "extends": [ + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking" + ], + "env": { + "browser": true + }, + "rules": { + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/explicit-member-accessibility": "error", + "@typescript-eslint/prefer-return-this-type": "error", + "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}] + } + } + ] } diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 80a6b81..f1a4b9e 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -14,7 +14,7 @@ jobs: # - uses: actions/checkout@v3 # - uses: actions/setup-node@v3 # with: - # node-version: 16 + # node-version: 18 # - run: npm ci # - run: npm test @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 registry-url: https://registry.npmjs.org/ - run: npm ci - run: npm publish diff --git a/package-lock.json b/package-lock.json index ad939f1..6b5a4a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "1.5.1", "license": "MIT", "devDependencies": { + "@rollup/plugin-typescript": "^8.3.4", + "@typescript-eslint/eslint-plugin": "^5.31.0", + "@typescript-eslint/parser": "^5.31.0", "eslint": "^8.20.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", @@ -169,6 +172,87 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-8.3.4.tgz", + "integrity": "sha512-wt7JnYE9antX6BOXtsxGoeVSu4dZfw0dU3xykfOQ4hC3EddxRbVG/K0xiY1Wup7QOHJcjLYXWAn0Kx9Z1SBHHg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "resolve": "^1.17.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -184,12 +268,230 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, + "node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", + "dev": true + }, "node_modules/@types/node": { "version": "18.0.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.5.tgz", "integrity": "sha512-En7tneq+j0qAiVwysBD79y86MT3ModuoIJbe7JXp+sb5UAjInSShmK3nXXMioBzfF7rXC12hv12d4IyCVwN4dA==", "dev": true }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.31.0.tgz", + "integrity": "sha512-VKW4JPHzG5yhYQrQ1AzXgVgX8ZAJEvCz0QI6mLRX4tf7rnFfh5D8SKm0Pq6w5PyNfAWJk6sv313+nEt3ohWMBQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "5.31.0", + "@typescript-eslint/type-utils": "5.31.0", + "@typescript-eslint/utils": "5.31.0", + "debug": "^4.3.4", + "functional-red-black-tree": "^1.0.1", + "ignore": "^5.2.0", + "regexpp": "^3.2.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.31.0.tgz", + "integrity": "sha512-UStjQiZ9OFTFReTrN+iGrC6O/ko9LVDhreEK5S3edmXgR396JGq7CoX2TWIptqt/ESzU2iRKXAHfSF2WJFcWHw==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "5.31.0", + "@typescript-eslint/types": "5.31.0", + "@typescript-eslint/typescript-estree": "5.31.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.31.0.tgz", + "integrity": "sha512-8jfEzBYDBG88rcXFxajdVavGxb5/XKXyvWgvD8Qix3EEJLCFIdVloJw+r9ww0wbyNLOTYyBsR+4ALNGdlalLLg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.31.0", + "@typescript-eslint/visitor-keys": "5.31.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.31.0.tgz", + "integrity": "sha512-7ZYqFbvEvYXFn9ax02GsPcEOmuWNg+14HIf4q+oUuLnMbpJ6eHAivCg7tZMVwzrIuzX3QCeAOqKoyMZCv5xe+w==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "5.31.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.31.0.tgz", + "integrity": "sha512-/f/rMaEseux+I4wmR6mfpM2wvtNZb1p9hAV77hWfuKc3pmaANp5dLAZSiE3/8oXTYTt3uV9KW5yZKJsMievp6g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.31.0.tgz", + "integrity": "sha512-3S625TMcARX71wBc2qubHaoUwMEn+l9TCsaIzYI/ET31Xm2c9YQ+zhGgpydjorwQO9pLfR/6peTzS/0G3J/hDw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.31.0", + "@typescript-eslint/visitor-keys": "5.31.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.31.0.tgz", + "integrity": "sha512-kcVPdQS6VIpVTQ7QnGNKMFtdJdvnStkqS5LeALr4rcwx11G6OWb2HB17NMPnlRHvaZP38hL9iK8DdE9Fne7NYg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.31.0", + "@typescript-eslint/types": "5.31.0", + "@typescript-eslint/typescript-estree": "5.31.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.31.0.tgz", + "integrity": "sha512-ZK0jVxSjS4gnPirpVjXHz7mgdOsZUHzNYSfTw2yPa3agfbt9YfqaBiBZFSSxeBWnpWkzCxTfUpnzA3Vily/CSg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.31.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/acorn": { "version": "8.7.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", @@ -248,6 +550,15 @@ "node": ">=4" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -270,6 +581,18 @@ "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.2.tgz", @@ -590,6 +913,18 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1004,6 +1339,34 @@ "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", "dev": true }, + "node_modules/fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "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", @@ -1016,6 +1379,15 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -1028,6 +1400,18 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -1135,6 +1519,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -1287,6 +1691,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1401,6 +1814,18 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "dev": true }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", @@ -1413,6 +1838,28 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1579,12 +2026,33 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pify": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", @@ -2207,6 +2675,26 @@ "node": ">=6" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -2254,6 +2742,16 @@ "node": ">=4" } }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -2406,6 +2904,29 @@ "estree-walker": "^0.6.1" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2432,6 +2953,21 @@ "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", "dev": true }, + "node_modules/semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/serialize-javascript": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", @@ -2462,6 +2998,15 @@ "node": ">=8" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2625,6 +3170,39 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2649,6 +3227,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.4.tgz", @@ -2726,6 +3318,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", @@ -2863,6 +3461,61 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@rollup/plugin-typescript": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-8.3.4.tgz", + "integrity": "sha512-wt7JnYE9antX6BOXtsxGoeVSu4dZfw0dU3xykfOQ4hC3EddxRbVG/K0xiY1Wup7QOHJcjLYXWAn0Kx9Z1SBHHg==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.1.0", + "resolve": "^1.17.0" + } + }, + "@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "requires": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "dependencies": { + "estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + } + } + }, "@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -2875,12 +3528,137 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, + "@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "@types/json-schema": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", + "dev": true + }, "@types/node": { "version": "18.0.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.5.tgz", "integrity": "sha512-En7tneq+j0qAiVwysBD79y86MT3ModuoIJbe7JXp+sb5UAjInSShmK3nXXMioBzfF7rXC12hv12d4IyCVwN4dA==", "dev": true }, + "@typescript-eslint/eslint-plugin": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.31.0.tgz", + "integrity": "sha512-VKW4JPHzG5yhYQrQ1AzXgVgX8ZAJEvCz0QI6mLRX4tf7rnFfh5D8SKm0Pq6w5PyNfAWJk6sv313+nEt3ohWMBQ==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.31.0", + "@typescript-eslint/type-utils": "5.31.0", + "@typescript-eslint/utils": "5.31.0", + "debug": "^4.3.4", + "functional-red-black-tree": "^1.0.1", + "ignore": "^5.2.0", + "regexpp": "^3.2.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/parser": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.31.0.tgz", + "integrity": "sha512-UStjQiZ9OFTFReTrN+iGrC6O/ko9LVDhreEK5S3edmXgR396JGq7CoX2TWIptqt/ESzU2iRKXAHfSF2WJFcWHw==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.31.0", + "@typescript-eslint/types": "5.31.0", + "@typescript-eslint/typescript-estree": "5.31.0", + "debug": "^4.3.4" + } + }, + "@typescript-eslint/scope-manager": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.31.0.tgz", + "integrity": "sha512-8jfEzBYDBG88rcXFxajdVavGxb5/XKXyvWgvD8Qix3EEJLCFIdVloJw+r9ww0wbyNLOTYyBsR+4ALNGdlalLLg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.31.0", + "@typescript-eslint/visitor-keys": "5.31.0" + } + }, + "@typescript-eslint/type-utils": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.31.0.tgz", + "integrity": "sha512-7ZYqFbvEvYXFn9ax02GsPcEOmuWNg+14HIf4q+oUuLnMbpJ6eHAivCg7tZMVwzrIuzX3QCeAOqKoyMZCv5xe+w==", + "dev": true, + "requires": { + "@typescript-eslint/utils": "5.31.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/types": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.31.0.tgz", + "integrity": "sha512-/f/rMaEseux+I4wmR6mfpM2wvtNZb1p9hAV77hWfuKc3pmaANp5dLAZSiE3/8oXTYTt3uV9KW5yZKJsMievp6g==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.31.0.tgz", + "integrity": "sha512-3S625TMcARX71wBc2qubHaoUwMEn+l9TCsaIzYI/ET31Xm2c9YQ+zhGgpydjorwQO9pLfR/6peTzS/0G3J/hDw==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.31.0", + "@typescript-eslint/visitor-keys": "5.31.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/utils": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.31.0.tgz", + "integrity": "sha512-kcVPdQS6VIpVTQ7QnGNKMFtdJdvnStkqS5LeALr4rcwx11G6OWb2HB17NMPnlRHvaZP38hL9iK8DdE9Fne7NYg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.31.0", + "@typescript-eslint/types": "5.31.0", + "@typescript-eslint/typescript-estree": "5.31.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "dependencies": { + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + } + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.31.0.tgz", + "integrity": "sha512-ZK0jVxSjS4gnPirpVjXHz7mgdOsZUHzNYSfTw2yPa3agfbt9YfqaBiBZFSSxeBWnpWkzCxTfUpnzA3Vily/CSg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.31.0", + "eslint-visitor-keys": "^3.3.0" + } + }, "acorn": { "version": "8.7.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz", @@ -2921,6 +3699,12 @@ "color-convert": "^1.9.0" } }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2943,6 +3727,15 @@ "concat-map": "0.0.1" } }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, "browserslist": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.2.tgz", @@ -3170,6 +3963,15 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3469,6 +4271,30 @@ "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", "dev": true }, + "fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, "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", @@ -3481,6 +4307,15 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3490,6 +4325,15 @@ "flat-cache": "^3.0.4" } }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -3572,6 +4416,20 @@ "type-fest": "^0.20.2" } }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -3688,6 +4546,12 @@ "is-extglob": "^2.1.1" } }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3786,6 +4650,15 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "dev": true }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, "mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", @@ -3798,6 +4671,22 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3922,12 +4811,24 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, "pify": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", @@ -4308,6 +5209,12 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -4340,6 +5247,12 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -4452,6 +5365,15 @@ "estree-walker": "^0.6.1" } }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4464,6 +5386,15 @@ "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", "dev": true }, + "semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, "serialize-javascript": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", @@ -4488,6 +5419,12 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4610,6 +5547,30 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4625,6 +5586,13 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true }, + "typescript": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true, + "peer": true + }, "update-browserslist-db": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.4.tgz", @@ -4677,6 +5645,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/package.json b/package.json index 9c8acd1..2753b71 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev": "rollup -c --watch", "build": "rollup -c", "prepare": "npm run build", - "lint": "eslint \"./src/**.js\"" + "lint": "eslint \"./src/**.ts\"" }, "files": [ "dist/" @@ -30,6 +30,9 @@ }, "homepage": "https://leopardjs.com/", "devDependencies": { + "@rollup/plugin-typescript": "^8.3.4", + "@typescript-eslint/eslint-plugin": "^5.31.0", + "@typescript-eslint/parser": "^5.31.0", "eslint": "^8.20.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", diff --git a/rollup.config.js b/rollup.config.js index a76f3d6..c488056 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,23 +1,25 @@ import postcss from "rollup-plugin-postcss"; import { terser } from "rollup-plugin-terser"; +import typescript from "@rollup/plugin-typescript"; export default [ { - input: "src/index.js", + input: "src/index.ts", output: [ { file: "dist/index.esm.js", format: "esm", - sourcemap: true + sourcemap: true, }, { file: "dist/index.umd.js", format: "umd", name: "leopard", - sourcemap: true - } + sourcemap: true, + }, ], plugins: [ + typescript(), terser({ output: { comments: (node, comment) => { @@ -26,22 +28,22 @@ export default [ return /license/i.test(comment.value); } return false; - } - } - }) - ] + }, + }, + }), + ], }, { input: "src/index.css", output: { - file: "dist/index.min.css" + file: "dist/index.min.css", }, plugins: [ postcss({ modules: false, extract: true, - minimize: true - }) - ] - } + minimize: true, + }), + ], + }, ]; diff --git a/src/Color.js b/src/Color.ts similarity index 67% rename from src/Color.js rename to src/Color.ts index 674c8ea..4d1dd22 100644 --- a/src/Color.js +++ b/src/Color.ts @@ -1,7 +1,16 @@ -const clamp = (n, min, max) => Math.max(min, Math.min(max, n)); +const clamp = (n: number, min: number, max: number): number => + Math.max(min, Math.min(max, n)); // https://www.rapidtables.com/convert/color/rgb-to-hsv.html -function rgbToHSV(r, g, b) { +function rgbToHSV( + r: number, + g: number, + b: number +): { + h: number; + s: number; + v: number; +} { r /= 255; g /= 255; b /= 255; @@ -26,17 +35,21 @@ function rgbToHSV(r, g, b) { s = delta / max; } - let v = max; + const v = max; return { h: h * 100, s: s * 100, - v: v * 100 + v: v * 100, }; } // https://www.rapidtables.com/convert/color/hsv-to-rgb.html -function hsvToRGB(h, s, v) { +function hsvToRGB( + h: number, + s: number, + v: number +): { r: number; g: number; b: number } { h = (h / 100) * 360; s /= 100; v /= 100; @@ -73,28 +86,43 @@ function hsvToRGB(h, s, v) { return { r: r * 255, g: g * 255, - b: b * 255 + b: b * 255, }; } +/** + * RGBA color, with each component going from 0 to 255. Components may still be decimal. + */ +export type RGBA = [number, number, number, number]; + +/** + * RGBA color, with each component going from 0 to 1. + */ +export type RGBANormalized = [number, number, number, number]; + export default class Color { - constructor(h = 0, s = 0, v = 0, a = 1) { + private _h = 0; + private _s = 0; + private _v = 0; + private _a = 1; + + public constructor(h = 0, s = 0, v = 0, a = 1) { this.h = h; this.s = s; this.v = v; this.a = a; } - static rgb(r, g, b, a = 1) { + public static rgb(r: number, g: number, b: number, a = 1): Color { const { h, s, v } = rgbToHSV(r, g, b); return new Color(h, s, v, a); } - static hsv(h, s, v, a = 1) { + public static hsv(h: number, s: number, v: number, a = 1): Color { return new Color(h, s, v, a); } - static num(n) { + public static num(n: number | string): Color { n = Number(n); // Match Scratch rgba system @@ -107,62 +135,62 @@ export default class Color { } // Red - get r() { + public get r(): number { return hsvToRGB(this.h, this.s, this.v).r; } - set r(r) { + public set r(r) { this._setRGB(r, this.g, this.b); } // Green - get g() { + public get g(): number { return hsvToRGB(this.h, this.s, this.v).g; } - set g(g) { + public set g(g) { this._setRGB(this.r, g, this.b); } // Blue - get b() { + public get b(): number { return hsvToRGB(this.h, this.s, this.v).b; } - set b(b) { + public set b(b) { this._setRGB(this.r, this.g, b); } // Alpha - get a() { + public get a(): number { return this._a; } - set a(a) { + public set a(a) { this._a = clamp(a, 0, 1); } // Hue - get h() { + public get h(): number { return this._h; } - set h(h) { + public set h(h) { this._h = ((h % 100) + 100) % 100; } // Shade - get s() { + public get s(): number { return this._s; } - set s(s) { + public set s(s) { this._s = clamp(s, 0, 100); } // Value - get v() { + public get v(): number { return this._v; } - set v(v) { + public set v(v) { this._v = clamp(v, 0, 100); } - _setRGB(r, g, b) { + private _setRGB(r: number, g: number, b: number): void { r = clamp(r, 0, 255); g = clamp(g, 0, 255); b = clamp(b, 0, 255); @@ -174,8 +202,8 @@ export default class Color { this.v = v; } - toHexString(forceIncludeAlpha = false) { - const toHexDigits = n => { + public toHexString(forceIncludeAlpha = false): string { + const toHexDigits = (n: number): string => { n = clamp(Math.round(n), 0, 255); let str = n.toString(16); @@ -194,7 +222,7 @@ export default class Color { return hex; } - toRGBString(forceIncludeAlpha = false) { + public toRGBString(forceIncludeAlpha = false): string { const rgb = [this.r, this.g, this.b].map(Math.round); if (forceIncludeAlpha || this.a !== 1) { @@ -203,17 +231,17 @@ export default class Color { return `rgb(${rgb.join(", ")})`; } - toRGBA() { + public toRGBA(): RGBA { const rgb = hsvToRGB(this._h, this._s, this._v); return [rgb.r, rgb.g, rgb.b, this._a * 255]; } - toRGBANormalized() { + public toRGBANormalized(): RGBANormalized { const rgb = hsvToRGB(this._h, this._s, this._v); return [rgb.r / 255, rgb.g / 255, rgb.b / 255, this._a]; } - toString() { + public toString(): string { return this.toRGBString(); } } diff --git a/src/Costume.js b/src/Costume.ts similarity index 59% rename from src/Costume.js rename to src/Costume.ts index 5a0bd60..45bca04 100644 --- a/src/Costume.js +++ b/src/Costume.ts @@ -1,5 +1,12 @@ export default class Costume { - constructor(name, url, center = { x: 0, y: 0 }) { + public name: string; + public url: string; + public img: HTMLImageElement; + public isBitmap: boolean; + public resolution: 2 | 1; + public center: { x: number; y: number }; + + public constructor(name: string, url: string, center = { x: 0, y: 0 }) { this.name = name; this.url = url; @@ -14,11 +21,11 @@ export default class Costume { this.center = center; } - get width() { + public get width(): number { return this.img.naturalWidth; } - get height() { + public get height(): number { return this.img.naturalHeight; } } diff --git a/src/Input.js b/src/Input.ts similarity index 68% rename from src/Input.js rename to src/Input.ts index 4f9f207..12ac997 100644 --- a/src/Input.js +++ b/src/Input.ts @@ -1,5 +1,20 @@ +import type { Stage } from "./Sprite"; + +type Mouse = { x: number; y: number; down: boolean }; + export default class Input { - constructor(stage, canvas, onKeyDown) { + private _stage; + private _canvas; + private _onKeyDown; + + public mouse: Mouse; + public keys: string[]; + + public constructor( + stage: Stage, + canvas: HTMLCanvasElement, + onKeyDown: (key: string) => unknown + ) { this._stage = stage; this._canvas = canvas; @@ -20,42 +35,42 @@ export default class Input { this._onKeyDown = onKeyDown; } - _mouseMove(e) { + private _mouseMove(e: MouseEvent): void { const rect = this._canvas.getBoundingClientRect(); const scaleX = this._stage.width / rect.width; const scaleY = this._stage.height / rect.height; const realCoords = { x: (e.clientX - rect.left) * scaleX, - y: (e.clientY - rect.top) * scaleY + y: (e.clientY - rect.top) * scaleY, }; this.mouse = { ...this.mouse, x: realCoords.x - this._stage.width / 2, - y: -realCoords.y + this._stage.height / 2 + y: -realCoords.y + this._stage.height / 2, }; } - _mouseDown() { + private _mouseDown(): void { this.mouse = { ...this.mouse, - down: true + down: true, }; } - _mouseUp() { + private _mouseUp(): void { this.mouse = { ...this.mouse, - down: false + down: false, }; } - _keyup(e) { + private _keyup(e: KeyboardEvent): void { const key = this._getKeyName(e); - this.keys = this.keys.filter(k => k !== key); + this.keys = this.keys.filter((k) => k !== key); } - _keydown(e) { + private _keydown(e: KeyboardEvent): void { e.preventDefault(); const key = this._getKeyName(e); @@ -66,7 +81,7 @@ export default class Input { this._onKeyDown(key); } - _getKeyName(e) { + private _getKeyName(e: KeyboardEvent): string { if (e.key === "ArrowUp") return "up arrow"; if (e.key === "ArrowDown") return "down arrow"; if (e.key === "ArrowLeft") return "left arrow"; @@ -77,12 +92,14 @@ export default class Input { return e.key.toLowerCase(); } - keyPressed(name) { + public keyPressed(name: string): boolean { if (name === "any") return this.keys.length > 0; return this.keys.indexOf(name) > -1; } - focus() { + public focus(): void { this._canvas.focus(); } } + +export type { Mouse }; diff --git a/src/Loudness.js b/src/Loudness.ts similarity index 57% rename from src/Loudness.js rename to src/Loudness.ts index c2aaa7f..c9a6c20 100644 --- a/src/Loudness.js +++ b/src/Loudness.ts @@ -1,39 +1,54 @@ -import Sound from "./Sound.js"; +import Sound from "./Sound"; const IGNORABLE_ERROR = ["NotAllowedError", "NotFoundError"]; +const enum ConnectionState { + /** We have not tried connecting yet. */ + NOT_CONNECTED, + /** We are in the middle of connecting. */ + CONNECTING, + /** We connected successfully. */ + CONNECTED, + /** There was an error connecting. */ + ERROR, +} + // https://github.com/LLK/scratch-audio/blob/develop/src/Loudness.js export default class LoudnessHandler { - constructor() { - // TODO: use a TypeScript enum - this.connectionState = "NOT_CONNECTED"; + private connectionState: ConnectionState; + private audioStream: MediaStream | undefined; + private analyser: AnalyserNode | undefined; + private micDataArray: Float32Array | undefined; + private _lastValue: number | undefined; + + public constructor() { + this.connectionState = ConnectionState.NOT_CONNECTED; } - get audioContext() { + private get audioContext(): AudioContext { return Sound.audioContext; } - async connect() { + private async connect(): Promise { // If we're in the middle of connecting, or failed to connect, // don't attempt to connect again - if (this.connectionState !== "NOT_CONNECTED") return; - this.connectionState = "CONNECTING"; + if (this.connectionState !== ConnectionState.NOT_CONNECTED) return; + this.connectionState = ConnectionState.CONNECTING; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Chrome blocks usage of audio until the user interacts with the page. // By calling `resume` here, we will wait until that happens. await Sound.audioContext.resume(); - this.hasConnected = true; this.audioStream = stream; const mic = this.audioContext.createMediaStreamSource(stream); this.analyser = this.audioContext.createAnalyser(); mic.connect(this.analyser); this.micDataArray = new Float32Array(this.analyser.fftSize); - this.connectionState = "CONNECTED"; + this.connectionState = ConnectionState.CONNECTED; } catch (e) { - this.connectionState = "ERROR"; - if (IGNORABLE_ERROR.includes(e.name)) { + this.connectionState = ConnectionState.ERROR; + if (IGNORABLE_ERROR.includes((e as Error).name)) { console.warn("Mic is not available."); } else { throw e; @@ -41,8 +56,13 @@ export default class LoudnessHandler { } } - get loudness() { - if (this.connectionState !== "CONNECTED" || !this.audioStream.active) { + private get loudness(): number { + if ( + this.connectionState !== ConnectionState.CONNECTED || + !this.audioStream?.active || + !this.analyser || + !this.micDataArray + ) { return -1; } @@ -67,8 +87,8 @@ export default class LoudnessHandler { return rms; } - getLoudness() { - this.connect(); + public getLoudness(): number { + void this.connect(); return this.loudness; } } diff --git a/src/Project.js b/src/Project.ts similarity index 55% rename from src/Project.js rename to src/Project.ts index 66d2456..ef4f753 100644 --- a/src/Project.js +++ b/src/Project.ts @@ -1,11 +1,36 @@ -import Trigger from "./Trigger.js"; -import Renderer from "./Renderer.js"; -import Input from "./Input.js"; -import LoudnessHandler from "./Loudness.js"; -import Sound from "./Sound.js"; +import Trigger, { TriggerCreator, TriggerOptions } from "./Trigger"; +import Renderer from "./Renderer"; +import Input from "./Input"; +import LoudnessHandler from "./Loudness"; +import Sound from "./Sound"; +import type { Stage, Sprite } from "./Sprite"; + +type TriggerWithTarget = { + target: Sprite | Stage; + trigger: Trigger; +}; export default class Project { - constructor(stage, sprites = {}, { frameRate = 30 } = {}) { + public stage: Stage; + public sprites: Partial>; + public renderer: Renderer; + public input: Input; + + private loudnessHandler: LoudnessHandler; + private _cachedLoudness: number | null; + + public runningTriggers: TriggerWithTarget[]; + + public answer: string | null; + private timerStart!: Date; + + /** + * Used to keep track of what edge-activated trigger predicates evaluted to + * on the previous step. + */ + private _prevStepTriggerPredicates: WeakMap; + + public constructor(stage: Stage, sprites = {}, { frameRate = 30 } = {}) { this.stage = stage; this.sprites = sprites; @@ -16,9 +41,9 @@ export default class Project { } this.stage._project = this; - this.renderer = new Renderer(this); - this.input = new Input(this.stage, this.renderer.stage, key => { - this.fireTrigger(Trigger.KEY_PRESSED, { key }); + this.renderer = new Renderer(this, null); + this.input = new Input(this.stage, this.renderer.stage, (key) => { + void this.fireTrigger(Trigger.keyPressed, { key }); }); this.loudnessHandler = new LoudnessHandler(); @@ -26,8 +51,6 @@ export default class Project { this._cachedLoudness = null; this.runningTriggers = []; - // Used to keep track of what edge-activated trigger predicates evaluted to - // on the previous step. this._prevStepTriggerPredicates = new WeakMap(); this.restartTimer(); @@ -43,7 +66,7 @@ export default class Project { this._renderLoop(); } - attach(renderTarget) { + public attach(renderTarget: string | HTMLElement): void { this.renderer.setRenderTarget(renderTarget); this.renderer.stage.addEventListener("click", () => { // Chrome requires a user gesture on the page before we can start the @@ -51,70 +74,73 @@ export default class Project { // When we click the stage, that counts as a user gesture, so try // resuming the audio context. if (Sound.audioContext.state === "suspended") { - Sound.audioContext.resume(); + void Sound.audioContext.resume(); } let clickedSprite = this.renderer.pick(this.spritesAndClones, { x: this.input.mouse.x, - y: this.input.mouse.y + y: this.input.mouse.y, }); if (!clickedSprite) { clickedSprite = this.stage; } - const matchingTriggers = []; + const matchingTriggers: TriggerWithTarget[] = []; for (const trigger of clickedSprite.triggers) { - if (trigger.matches(Trigger.CLICKED, {}, clickedSprite)) { + if (trigger.matches(Trigger.clicked, {}, clickedSprite)) { matchingTriggers.push({ trigger, target: clickedSprite }); } } - this._startTriggers(matchingTriggers); + void this._startTriggers(matchingTriggers); }); } - greenFlag() { + public greenFlag(): void { // Chrome requires a user gesture on the page before we can start the // audio context. // When greenFlag is triggered, it's likely that the cause of it was some // kind of button click, so try resuming the audio context. if (Sound.audioContext.state === "suspended") { - Sound.audioContext.resume(); + void Sound.audioContext.resume(); } - this.fireTrigger(Trigger.GREEN_FLAG); + void this.fireTrigger(Trigger.greenFlag); this.input.focus(); } // Find triggers which match the given condition - _matchingTriggers(triggerMatches) { - let matchingTriggers = []; + private _matchingTriggers( + triggerMatches: (trigger: Trigger, target: Sprite | Stage) => boolean + ): TriggerWithTarget[] { + const matchingTriggers: TriggerWithTarget[] = []; const targets = this.spritesAndStage; + for (const target of targets) { - const matchingTargetTriggers = target.triggers.filter(tr => - triggerMatches(tr, target) - ); - for (const match of matchingTargetTriggers) { - matchingTriggers.push({ trigger: match, target }); + for (const trigger of target.triggers) { + if (triggerMatches(trigger, target)) { + matchingTriggers.push({ trigger, target }); + } } } + return matchingTriggers; } - _stepEdgeActivatedTriggers() { - const edgeActivated = this._matchingTriggers(tr => tr.isEdgeActivated); - const triggersToStart = []; + private _stepEdgeActivatedTriggers(): void { + const edgeActivated = this._matchingTriggers((tr) => tr.isEdgeActivated); + const triggersToStart: TriggerWithTarget[] = []; for (const triggerWithTarget of edgeActivated) { const { trigger, target } = triggerWithTarget; let predicate; - switch (trigger.trigger) { - case Trigger.TIMER_GREATER_THAN: - predicate = this.timer > trigger.option("VALUE", target); - break; - case Trigger.LOUDNESS_GREATER_THAN: - predicate = this.loudness > trigger.option("VALUE", target); - break; - default: - throw new Error(`Unimplemented trigger ${trigger.trigger}`); + + // TODO: This is kind of awkward, can we use the Trigger.matches() + // options argument? + if (trigger.matches(Trigger.timerGreaterThan)) { + predicate = this.timer > trigger.option("VALUE", target)!; + } else if (trigger.matches(Trigger.loudnessGreaterThan)) { + predicate = this.loudness > trigger.option("VALUE", target)!; + } else { + throw new Error(`Unimplemented trigger ${String(trigger.trigger)}`); } // Default to false @@ -127,10 +153,10 @@ export default class Project { triggersToStart.push(triggerWithTarget); } } - this._startTriggers(triggersToStart); + void this._startTriggers(triggersToStart); } - step() { + private step(): void { this._cachedLoudness = null; this._stepEdgeActivatedTriggers(); @@ -146,32 +172,35 @@ export default class Project { ); } - render() { + private render(): void { // Render to canvas - this.renderer.update(this.stage, this.spritesAndClones); + this.renderer.update(); // Update watchers - for (const sprite of [...Object.values(this.sprites), this.stage]) { - for (const watcher of Object.values(sprite.watchers)) { - watcher.updateDOM(this.renderer.renderTarget); + if (this.renderer.renderTarget) { + for (const sprite of [...Object.values(this.sprites), this.stage]) { + for (const watcher of Object.values(sprite!.watchers)) { + watcher!.updateDOM(this.renderer.renderTarget); + } } } } - _renderLoop() { + private _renderLoop(): void { requestAnimationFrame(this._renderLoop.bind(this)); this.render(); } - fireTrigger(trigger, options) { + // TODO: Only accept TriggerCreator. + public fireTrigger(creator: TriggerCreator | symbol, options?: TriggerOptions): Promise { // Special trigger behaviors - if (trigger === Trigger.GREEN_FLAG) { + if (Trigger.matches(creator, Trigger.greenFlag)) { this.restartTimer(); this.stopAllSounds(); this.runningTriggers = []; for (const spriteName in this.sprites) { - const sprite = this.sprites[spriteName]; + const sprite = this.sprites[spriteName]!; sprite.clones = []; } @@ -181,21 +210,22 @@ export default class Project { } } - const matchingTriggers = this._matchingTriggers((tr, target) => - tr.matches(trigger, options, target) + const matchingTriggers = this._matchingTriggers((trigger, target) => + trigger.matches(creator, options, target) ); return this._startTriggers(matchingTriggers); } - _startTriggers(triggers) { + // TODO: add a way to start clone triggers from fireTrigger then make this private + public async _startTriggers(triggers: TriggerWithTarget[]): Promise { // Only add these triggers to this.runningTriggers if they're not already there. // TODO: if the triggers are already running, they'll be restarted but their execution order is unchanged. // Does that match Scratch's behavior? for (const trigger of triggers) { if ( !this.runningTriggers.find( - runningTrigger => + (runningTrigger) => trigger.trigger === runningTrigger.trigger && trigger.target === runningTrigger.target ) @@ -203,25 +233,27 @@ export default class Project { this.runningTriggers.push(trigger); } } - return Promise.all( - triggers.map(({ trigger, target }) => { - return trigger.start(target); - }) + await Promise.all( + triggers.map(({ trigger, target }) => trigger.start(target)) ); } - get spritesAndClones() { + public get spritesAndClones(): Sprite[] { return Object.values(this.sprites) - .flatMap(sprite => sprite.andClones()) + .flatMap((sprite) => sprite!.andClones()) .sort((a, b) => a._layerOrder - b._layerOrder); } - get spritesAndStage() { + public get spritesAndStage(): (Sprite | Stage)[] { return [...this.spritesAndClones, this.stage]; } - changeSpriteLayer(sprite, layerDelta, relativeToSprite = sprite) { - let spritesArray = this.spritesAndClones; + public changeSpriteLayer( + sprite: Sprite, + layerDelta: number, + relativeToSprite = sprite + ): void { + const spritesArray = this.spritesAndClones; const originalIndex = spritesArray.indexOf(sprite); const relativeToIndex = spritesArray.indexOf(relativeToSprite); @@ -242,26 +274,26 @@ export default class Project { }); } - stopAllSounds() { + public stopAllSounds(): void { for (const target of this.spritesAndStage) { target.stopAllOfMySounds(); } } - get timer() { - const ms = new Date() - this.timerStart; + public get timer(): number { + const ms = new Date().getTime() - this.timerStart.getTime(); return ms / 1000; } - restartTimer() { + public restartTimer(): void { this.timerStart = new Date(); } - async askAndWait(question) { + public async askAndWait(question: string): Promise { this.answer = await this.renderer.displayAskBox(question); } - get loudness() { + public get loudness(): number { if (this._cachedLoudness === null) { this._cachedLoudness = this.loudnessHandler.getLoudness(); } diff --git a/src/Renderer.js b/src/Renderer.ts similarity index 73% rename from src/Renderer.js rename to src/Renderer.ts index 3b8cede..cad41ba 100644 --- a/src/Renderer.js +++ b/src/Renderer.ts @@ -1,14 +1,19 @@ -import Matrix from "./renderer/Matrix.js"; -import Drawable from "./renderer/Drawable.js"; -import BitmapSkin from "./renderer/BitmapSkin.js"; -import PenSkin from "./renderer/PenSkin.js"; -import SpeechBubbleSkin from "./renderer/SpeechBubbleSkin.js"; -import VectorSkin from "./renderer/VectorSkin.js"; -import Rectangle from "./renderer/Rectangle.js"; -import ShaderManager from "./renderer/ShaderManager.js"; -import { effectNames, effectBitmasks } from "./renderer/effectInfo.js"; - -import Costume from "./Costume.js"; +import Matrix, { MatrixType } from "./renderer/Matrix"; +import Drawable from "./renderer/Drawable"; +import BitmapSkin from "./renderer/BitmapSkin"; +import PenSkin from "./renderer/PenSkin"; +import SpeechBubbleSkin from "./renderer/SpeechBubbleSkin"; +import VectorSkin from "./renderer/VectorSkin"; +import Rectangle from "./renderer/Rectangle"; +import ShaderManager, { Shader, DrawMode } from "./renderer/ShaderManager"; +import { effectNames, effectBitmasks } from "./renderer/effectInfo"; +import type Skin from "./renderer/Skin"; + +import Costume from "./Costume"; +import type Color from "./Color"; +import type { RGBANormalized } from "./Color"; +import type Project from "./Project"; +import { Sprite, Stage, _EffectMap, SpeechBubble } from "./Sprite"; // Rectangle used for checking collision bounds. // Rather than create a new one each time, we can just reuse this one. @@ -18,23 +23,59 @@ const __collisionBox = new Rectangle(); // stored in the blue channel, then green, then red. // RGB [0, 0, 0] is reserved for "no sprite here". // This allows for up to 2^24 - 2 different sprites to be rendered at once. -const idToColor = id => [ +const idToColor = (id: number): [number, number, number] => [ (((id + 1) >> 16) & 0xff) / 255, (((id + 1) >> 8) & 0xff) / 255, - ((id + 1) & 0xff) / 255 + ((id + 1) & 0xff) / 255, ]; // Convert a 24-bit color back into a sprite ID/index number. // -1 means "no sprite here". -const colorToId = ([r, g, b]) => ((r << 16) | (g << 8) | b) - 1; +const colorToId = ([r, g, b]: [number, number, number] | Uint8Array): number => + ((r << 16) | (g << 8) | b) - 1; + +type RenderSpriteOptions = { + drawMode: DrawMode; + effectMask?: number; + colorMask?: RGBANormalized; + renderSpeechBubbles?: boolean; + spriteColorId?: (target: Sprite | Stage) => number; +}; + +export type FramebufferInfo = { + texture: WebGLTexture; + width: number; + height: number; + framebuffer: WebGLFramebuffer; +}; export default class Renderer { - constructor(project, renderTarget) { + public project: Project; + public stage: HTMLCanvasElement; + public gl: WebGLRenderingContext; + public renderTarget: HTMLElement | null = null; + + public _shaderManager: ShaderManager; + private _drawables: WeakMap; + private _skins: WeakMap; + + private _currentShader: Shader | null; + private _currentFramebuffer: WebGLFramebuffer | null; + private _screenSpaceScale: number; + private _penSkin: PenSkin; + private _collisionBuffer: FramebufferInfo; + + public constructor( + project: Project, + renderTarget: HTMLElement | string | null + ) { const w = project.stage.width; const h = project.stage.height; this.project = project; - this.stage = this.createStage(w, h); - this.gl = this.stage.getContext("webgl", { antialias: false }); + this.stage = Renderer.createStage(w, h); + const gl = this.stage.getContext("webgl", { antialias: false }); + if (gl === null) throw new Error("Could not initialize WebGL context"); + this.gl = gl; if (renderTarget) { this.setRenderTarget(renderTarget); @@ -51,7 +92,6 @@ export default class Renderer { this._screenSpaceScale = 1; // Initialize a bunch of WebGL state - const gl = this.gl; // Use premultiplied alpha for proper color blending. gl.enable(gl.BLEND); @@ -86,10 +126,9 @@ export default class Renderer { } // Retrieve a given object (e.g. costume or speech bubble)'s skin. If it doesn't exist, make one. - _getSkin(obj) { - if (this._skins.has(obj)) { - return this._skins.get(obj); - } + public _getSkin(obj: Costume | SpeechBubble): Skin { + const existingSkin = this._skins.get(obj); + if (existingSkin) return existingSkin; let skin; @@ -108,10 +147,10 @@ export default class Renderer { } // Retrieve the renderer-specific data object for a given sprite or clone. If it doesn't exist, make one. - _getDrawable(sprite) { - if (this._drawables.has(sprite)) { - return this._drawables.get(sprite); - } + public _getDrawable(sprite: Sprite | Stage): Drawable { + const existingDrawable = this._drawables.get(sprite); + if (existingDrawable) return existingDrawable; + const drawable = new Drawable(this, sprite); this._drawables.set(sprite, drawable); return drawable; @@ -121,10 +160,18 @@ export default class Renderer { // * The framebuffer itself. // * The texture backing the framebuffer. // * The resolution (width and height) of the framebuffer. - _createFramebufferInfo(width, height, filtering, stencil = false) { + public _createFramebufferInfo( + width: number, + height: number, + filtering: + | WebGLRenderingContext["NEAREST"] + | WebGLRenderingContext["LINEAR"], + stencil = false + ): FramebufferInfo { // Create an empty texture with this skin's dimensions. const gl = this.gl; const texture = gl.createTexture(); + if (texture === null) throw new Error("Could not create texture"); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); @@ -142,13 +189,16 @@ export default class Renderer { null ); + const framebuffer = gl.createFramebuffer(); + if (!framebuffer) throw new Error("Could not create framebuffer"); + // Create a framebuffer backed by said texture. This means we can draw onto the framebuffer, // and the results appear in the texture. const framebufferInfo = { texture, width, height, - framebuffer: gl.createFramebuffer() + framebuffer, }; this._setFramebuffer(framebufferInfo); gl.framebufferTexture2D( @@ -176,36 +226,34 @@ export default class Renderer { return framebufferInfo; } - _setShader(shader) { - if (shader !== this._currentShader) { - const gl = this.gl; - gl.useProgram(shader.program); - - // These attributes and uniforms don't ever change, but must be set whenever a new shader program is used. - - const attribLocation = shader.attribs.a_position; - gl.enableVertexAttribArray(attribLocation); - // Bind the 'a_position' vertex attribute to the current contents of `gl.ARRAY_BUFFER`, which in this case - // is a quadrilateral (as buffered earlier). - gl.vertexAttribPointer( - attribLocation, - 2, // every 2 array elements make one vertex. - gl.FLOAT, // data type - false, // normalized - 0, // stride (space between attributes) - 0 // offset (index of the first attribute to start from) - ); + public _setShader(shader: Shader): boolean { + if (shader === this._currentShader) return false; - this._currentShader = shader; - this._updateStageSize(); + const gl = this.gl; + gl.useProgram(shader.program); + + // These attributes and uniforms don't ever change, but must be set whenever a new shader program is used. + + const attribLocation = shader.attribs.a_position; + gl.enableVertexAttribArray(attribLocation); + // Bind the 'a_position' vertex attribute to the current contents of `gl.ARRAY_BUFFER`, which in this case + // is a quadrilateral (as buffered earlier). + gl.vertexAttribPointer( + attribLocation, + 2, // every 2 array elements make one vertex. + gl.FLOAT, // data type + false, // normalized + 0, // stride (space between attributes) + 0 // offset (index of the first attribute to start from) + ); - return true; - } + this._currentShader = shader; + this._updateStageSize(); - return false; + return true; } - _setFramebuffer(framebufferInfo) { + public _setFramebuffer(framebufferInfo: FramebufferInfo | null): void { if (framebufferInfo !== this._currentFramebuffer) { this._currentFramebuffer = framebufferInfo; if (framebufferInfo === null) { @@ -223,37 +271,38 @@ export default class Renderer { } } - setRenderTarget(renderTarget) { + public setRenderTarget(renderTarget: HTMLElement | string | null): void { if (typeof renderTarget === "string") { - renderTarget = document.querySelector(renderTarget); + renderTarget = document.querySelector(renderTarget) as HTMLElement; } this.renderTarget = renderTarget; - this.renderTarget.classList.add("leopard__project"); - this.renderTarget.style.width = `${this.project.stage.width}px`; - this.renderTarget.style.height = `${this.project.stage.height}px`; + if (!renderTarget) return; + renderTarget.classList.add("leopard__project"); + renderTarget.style.width = `${this.project.stage.width}px`; + renderTarget.style.height = `${this.project.stage.height}px`; - this.renderTarget.append(this.stage); + renderTarget.append(this.stage); } // Handles rendering of all layers (including stage, pen layer, sprites, and all clones) in proper order. - _renderLayers(layers, options = {}) { - options = Object.assign( - { - drawMode: ShaderManager.DrawModes.DEFAULT, - renderSpeechBubbles: true - }, - options - ); + private _renderLayers( + layers?: Set, + optionsIn: Partial = {}, + filter?: (layer: Sprite | Stage | PenSkin) => boolean + ): void { + const options = { + drawMode: ShaderManager.DrawModes.DEFAULT, + ...optionsIn, + }; // If we're given a list of layers, filter by that. // If we're given a filter function in the options, filter by that too. // If we're given both, then only include layers which match both. const shouldRestrictLayers = layers instanceof Set; - const shouldFilterLayers = typeof options.filter === "function"; - const shouldIncludeLayer = layer => + const shouldIncludeLayer = (layer: Sprite | Stage | PenSkin): boolean => !( (shouldRestrictLayers && !layers.has(layer)) || - (shouldFilterLayers && !options.filter(layer)) + (filter && !filter(layer)) ); // Stage @@ -289,7 +338,7 @@ export default class Renderer { } } - _updateStageSize() { + private _updateStageSize(): void { if (this._currentShader) { // The shader is passed things in "Scratch-space" (-240, 240) and (-180, 180). // This tells it those dimensions so it can convert them to OpenGL "clip-space" (-1, 1). @@ -311,7 +360,7 @@ export default class Renderer { } // Keep the canvas size in sync with the CSS size. - _resize() { + private _resize(): void { const stageSize = this.stage.getBoundingClientRect(); const ratio = window.devicePixelRatio; const adjustedWidth = Math.round(stageSize.width * ratio); @@ -331,7 +380,7 @@ export default class Renderer { } } - update() { + public update(): void { this._resize(); // Draw to the screen, not to a framebuffer. @@ -345,7 +394,7 @@ export default class Renderer { this._renderLayers(); } - createStage(w, h) { + private static createStage(w: number, h: number): HTMLCanvasElement { const stage = document.createElement("canvas"); stage.width = w; stage.height = h; @@ -365,7 +414,10 @@ export default class Renderer { } // Calculate the transform matrix for a speech bubble attached to a sprite. - _calculateSpeechBubbleMatrix(spr, speechBubbleSkin) { + private _calculateSpeechBubbleMatrix( + spr: Sprite, + speechBubbleSkin: SpeechBubbleSkin + ): MatrixType { const sprBounds = this.getBoundingBox(spr); let x; if ( @@ -388,16 +440,16 @@ export default class Renderer { return m; } - _renderSkin( - skin, - drawMode, - matrix, - scale, - effects, - effectMask, - colorMask, - spriteColorId - ) { + private _renderSkin( + skin: Skin, + drawMode: DrawMode, + matrix: MatrixType, + scale: number, + effects?: _EffectMap, + effectMask?: number, + colorMask?: RGBANormalized, + spriteColorId?: number + ): void { const gl = this.gl; const skinTexture = skin.getTexture(scale * this._screenSpaceScale); @@ -410,16 +462,20 @@ export default class Renderer { this._setShader(shader); gl.uniformMatrix3fv(shader.uniforms.u_transform, false, matrix); - if (effectBitmask !== 0) { + if (effectBitmask !== 0 && effects) { for (const effect of effectNames) { - const effectVal = effects._effectValues[effect]; + const effectVal = effects[effect]; if (effectVal !== 0) gl.uniform1f(shader.uniforms[`u_${effect}`], effectVal); } // Pixelate effect needs the skin size - if (effects._effectValues.pixelate !== 0) - gl.uniform2f(shader.uniforms.u_skinSize, skin.width, skin.height); + if (effects.pixelate !== 0) + gl.uniform2f( + shader.uniforms.u_skinSize, + skin.width ?? 0, + skin.height ?? 0 + ); } gl.bindTexture(gl.TEXTURE_2D, skinTexture); @@ -428,26 +484,24 @@ export default class Renderer { // Enable color masking mode if set if (Array.isArray(colorMask)) - this.gl.uniform4fv(this._currentShader.uniforms.u_colorMask, colorMask); + this.gl.uniform4fv(shader.uniforms.u_colorMask, colorMask); // Used for mapping drawn sprites back to their indices in a list. // By looking at the color of a given pixel, we can tell which sprite is // the topmost one drawn on that pixel. if (drawMode === ShaderManager.DrawModes.SPRITE_ID && typeof spriteColorId === "number") { - this.gl.uniform3fv( - this._currentShader.uniforms.u_spriteId, - idToColor(spriteColorId) - ); + this.gl.uniform3fv(shader.uniforms.u_spriteId, idToColor(spriteColorId)); } // Actually draw the skin this.gl.drawArrays(this.gl.TRIANGLES, 0, 6); } - renderSprite(sprite, options) { - const spriteScale = Object.prototype.hasOwnProperty.call(sprite, "size") - ? sprite.size / 100 - : 1; + private renderSprite( + sprite: Sprite | Stage, + options: RenderSpriteOptions + ): void { + const spriteScale = "size" in sprite ? sprite.size / 100 : 1; this._renderSkin( this._getSkin(sprite.costume), @@ -461,11 +515,15 @@ export default class Renderer { ); if ( - options.renderSpeechBubbles && + options.renderSpeechBubbles !== false && + "_speechBubble" in sprite && sprite._speechBubble && - sprite._speechBubble.text !== "" + sprite._speechBubble.text !== "" && + sprite instanceof Sprite ) { - const speechBubbleSkin = this._getSkin(sprite._speechBubble); + const speechBubbleSkin = this._getSkin( + sprite._speechBubble + ) as SpeechBubbleSkin; this._renderSkin( speechBubbleSkin, @@ -476,16 +534,16 @@ export default class Renderer { } } - getTightBoundingBox(sprite) { + public getTightBoundingBox(sprite: Sprite | Stage): Rectangle { return this._getDrawable(sprite).getTightBoundingBox(); } - getBoundingBox(sprite) { + public getBoundingBox(sprite: Sprite | Stage): Rectangle { return Rectangle.fromMatrix(this._getDrawable(sprite).getMatrix()); } // Mask drawing in to only areas where this sprite is opaque. - _stencilSprite(spr, colorMask) { + private _stencilSprite(spr: Sprite | Stage, colorMask?: Color): void { const gl = this.gl; gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); @@ -507,11 +565,16 @@ export default class Renderer { // This, along with the above line, has the effect of not drawing anything to the color buffer, but // creating a "mask" in the stencil buffer that masks out all pixels where this sprite is transparent. - const opts = { + const opts: { + drawMode: DrawMode; + renderSpeechBubbles: boolean; + effectMask: number; + colorMask?: RGBANormalized; + } & RenderSpriteOptions = { drawMode: ShaderManager.DrawModes.SILHOUETTE, renderSpeechBubbles: false, // Ignore ghost effect - effectMask: ~effectBitmasks.ghost + effectMask: ~effectBitmasks.ghost, }; // If we mask in the color (for e.g. "color is touching color"), @@ -530,8 +593,13 @@ export default class Renderer { gl.colorMask(true, true, true, true); } - checkSpriteCollision(spr, targets, fast, sprColor) { - if (!spr.visible) return false; + public checkSpriteCollision( + spr: Sprite | Stage, + targets: Set | (Sprite | Stage)[] | Sprite | Stage, + fast?: boolean, + sprColor?: Color + ): boolean { + if ("visible" in spr && !spr.visible) return false; if (!(targets instanceof Set)) { if (targets instanceof Array) { targets = new Set(targets); @@ -581,7 +649,7 @@ export default class Renderer { this._renderLayers(targets, { drawMode: ShaderManager.DrawModes.SILHOUETTE, // Ignore ghost effect - effectMask: ~effectBitmasks.ghost + effectMask: ~effectBitmasks.ghost, }); const gl = this.gl; @@ -609,7 +677,11 @@ export default class Renderer { return false; } - checkColorCollision(spr, targetsColor, sprColor) { + public checkColorCollision( + spr: Sprite | Stage, + targetsColor: Color, + sprColor?: Color + ): boolean { const sprBox = Rectangle.copy( this.getBoundingBox(spr), __collisionBox @@ -631,9 +703,7 @@ export default class Renderer { this._stencilSprite(spr, sprColor); // Render the sprites to check that we're touching, which will now be masked in to the area of the first sprite. - this._renderLayers(null, { - filter: layer => layer !== spr - }); + this._renderLayers(undefined, undefined, (layer) => layer !== spr); // Make sure to disable the stencil test so as not to affect other rendering! gl.disable(gl.STENCIL_TEST); @@ -667,7 +737,10 @@ export default class Renderer { } // Pick the topmost sprite at the given point (if one exists). - pick(sprites, point) { + public pick( + sprites: (Sprite | Stage)[], + point: { x: number; y: number } + ): Sprite | Stage | null { this._setFramebuffer(this._collisionBuffer); const gl = this.gl; gl.clearColor(0, 0, 0, 0); @@ -703,8 +776,12 @@ export default class Renderer { return sprites[index]; } - checkPointCollision(spr, point, fast) { - if (!spr.visible) return false; + public checkPointCollision( + spr: Sprite | Stage, + point: { x: number; y: number }, + fast?: boolean + ): boolean { + if ("visible" in spr && !spr.visible) return false; const box = this.getBoundingBox(spr); if (!box.containsPoint(point.x, point.y)) return false; @@ -733,20 +810,26 @@ export default class Renderer { return hoveredPixel[3] !== 0; } - penLine(pt1, pt2, color, size) { + public penLine( + pt1: { x: number; y: number }, + pt2: { x: number; y: number }, + color: Color, + size: number + ): void { this._penSkin.penLine(pt1, pt2, color, size); } - clearPen() { + public clearPen(): void { this._penSkin.clear(); } - stamp(spr) { + public stamp(spr: Sprite | Stage): void { this._setFramebuffer(this._penSkin._framebufferInfo); this._renderLayers(new Set([spr]), { renderSpeechBubbles: false }); } - displayAskBox(question) { + public displayAskBox(question: string): Promise { + if (!this.renderTarget) return Promise.resolve(""); const askBox = document.createElement("form"); askBox.classList.add("leopard__askBox"); @@ -768,8 +851,8 @@ export default class Renderer { this.renderTarget.append(askBox); askInput.focus(); - return new Promise(resolve => { - askBox.addEventListener("submit", e => { + return new Promise((resolve) => { + askBox.addEventListener("submit", (e) => { e.preventDefault(); askBox.remove(); resolve(askInput.value); diff --git a/src/Sound.js b/src/Sound.ts similarity index 64% rename from src/Sound.js rename to src/Sound.ts index f414372..74f4142 100644 --- a/src/Sound.js +++ b/src/Sound.ts @@ -1,7 +1,20 @@ -import decodeADPCMAudio, { isADPCMData } from "./lib/decode-adpcm-audio.js"; +import decodeADPCMAudio, { isADPCMData } from "./lib/decode-adpcm-audio"; +import type { Yielding } from "./lib/yielding"; export default class Sound { - constructor(name, url) { + public name: string; + public url: string; + + private audioBuffer: AudioBuffer | null; + private source: AudioBufferSourceNode | null; + private playbackRate: number; + private target: AudioNode | undefined; + + private _markDone: (() => void) | undefined; + private _doneDownloading: ((fromMoreRecentCall: boolean) => void) | undefined; + + private static _audioContext: AudioContext | undefined; + public constructor(name: string, url: string) { this.name = name; this.url = url; @@ -10,14 +23,14 @@ export default class Sound { this.playbackRate = 1; // TODO: Remove this line; initiate downloads from somewhere else instead. - this.downloadMyAudioBuffer(); + void this.downloadMyAudioBuffer(); } - get duration() { - return this.audioBuffer.duration; + public get duration(): number { + return this.audioBuffer ? this.audioBuffer.duration : 0; } - *start() { + public *start(): Yielding { let started = false; let isLatestCallToStart = true; @@ -51,7 +64,7 @@ export default class Sound { // finish playing. Of course, the latest call returns true, and so the // containing playUntilDone() (if present) knows to wait. const oldDoneDownloading = this._doneDownloading; - this._doneDownloading = fromMoreRecentCall => { + this._doneDownloading = (fromMoreRecentCall): void => { if (fromMoreRecentCall) { isLatestCallToStart = false; } else { @@ -70,14 +83,14 @@ export default class Sound { return isLatestCallToStart; } - *playUntilDone() { + public *playUntilDone(): Yielding { let playing = true; const isLatestCallToStart = yield* this.start(); // If we failed to download the audio buffer, just stop here - the sound will // never play, so it doesn't make sense to wait for it. - if (!this.audioBuffer) { + if (!this.audioBuffer || !this.source) { return; } @@ -97,7 +110,7 @@ export default class Sound { // is meant to be interrupted if another start() is ran while it's playing. // Of course, we don't want *this* playUntilDone() to be treated as though it // were interrupted when we call start(), so setting _markDone comes after. - this._markDone = () => { + this._markDone = (): void => { playing = false; delete this._markDone; }; @@ -105,7 +118,7 @@ export default class Sound { while (playing) yield; } - stop() { + public stop(): void { if (this._markDone) { this._markDone(); } @@ -116,35 +129,40 @@ export default class Sound { } } - downloadMyAudioBuffer() { + public downloadMyAudioBuffer(): Promise { return fetch(this.url) - .then(body => body.arrayBuffer()) - .then(arrayBuffer => { + .then((body) => body.arrayBuffer()) + .then((arrayBuffer) => { if (isADPCMData(arrayBuffer)) { return decodeADPCMAudio(arrayBuffer, Sound.audioContext).catch( - error => { + (error: Error) => { console.warn( - `Failed to load sound "${this.name}" - will not play:\n` + error + `Failed to load sound "${this.name}" - will not play:\n` + + error.toString() ); return null; } ); } else { - return new Promise((resolve, reject) => { - Sound.audioContext.decodeAudioData(arrayBuffer, resolve, reject); + return new Promise((resolve: DecodeSuccessCallback, reject) => { + void Sound.audioContext.decodeAudioData( + arrayBuffer, + resolve, + reject + ); }); } }) - .then(audioBuffer => { + .then((audioBuffer) => { this.audioBuffer = audioBuffer; if (this._doneDownloading) { - this._doneDownloading(); + this._doneDownloading(false); } return audioBuffer; }); } - playMyAudioBuffer() { + private playMyAudioBuffer(): void { if (!this.audioBuffer) { return; } @@ -164,7 +182,7 @@ export default class Sound { this.source.start(Sound.audioContext.currentTime); } - connect(target) { + public connect(target: AudioNode): void { if (target !== this.target) { this.target = target; if (this.source) { @@ -174,36 +192,174 @@ export default class Sound { } } - setPlaybackRate(value) { + public setPlaybackRate(value: number): void { this.playbackRate = value; if (this.source) { this.source.playbackRate.value = value; } } - isConnectedTo(target) { + public isConnectedTo(target: AudioNode): boolean { return this.target === target; } // Note: "this" refers to the Sound class in static functions. - static get audioContext() { - this._setupAudioContext(); - return this._audioContext; - } - - static _setupAudioContext() { + public static get audioContext(): AudioContext { if (!this._audioContext) { - const AudioContext = window.AudioContext || window.webkitAudioContext; + const AudioContext = + window.AudioContext || + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + ((window as any).webkitAudioContext as AudioContext); this._audioContext = new AudioContext(); } - } - - static decodeADPCMAudio(audioBuffer) { - return decodeADPCMAudio(audioBuffer, this.audioContext); + return this._audioContext; } } +// Instead of creating a basic Effect class and then implementing a subclass +// for each effect type, we use a simplified object-descriptor style. +// The makeNodes() function returns an object which is passed on to set(), so +// that effects are able to access a variety of nodes (or other values, if +// necessary) required to execute the desired effect. +// +// The code in makeNodes as well as the general definition for each effect is +// all graciously based on LLK's scratch-audio library. +// +// The initial value of an effect should always be the value at which the +// sound is not affected at all - i.e, it would be the same if the effect +// nodes were completely disconnected from the chain or otherwise had never +// been applied. This allows for clean discarding of effect nodes when returned +// to the initial value. +// +// The order of this array matches AudioEngine's effects list in scratch-audio. +// Earlier in the list is closer to the EffectChain input node; later is closer +// to its target (output). Note that a non-"patch" effect's position in the +// array has no bearing on effect behavior, since it isn't part of the chain +// system. +// +// Note that this descriptor list is fairly easy to build on, if we'd like to +// add more audio effects in the future. (Scratch used to have more, but they +// were removed - see commit ff6cd4a - because they depended on an external +// library and were too processor-intensive to support on some devices.) + +type EffectDescriptorBase = { + name: Name; + initial: number; + minimum?: number; + maximum?: number; + resetOnStart?: boolean; + resetOnClone?: boolean; +}; + +type PatchlessDescriptor = { + isPatch: false; + set: (value: number, sound: Sound) => void; +} & EffectDescriptorBase; + +type PatchDescriptor = { + isPatch: true; + makeNodes: () => Nodes & { input: AudioNode; output: AudioNode }; + set: ( + value: number, + nodes: Nodes & { input: AudioNode; output: AudioNode } + ) => void; +} & EffectDescriptorBase; + +type EffectDescriptor< + isPatch extends boolean, + Name extends string, + Nodes extends isPatch extends true ? object : never +> = isPatch extends true + ? PatchDescriptor + : PatchlessDescriptor; + +type Effects = { + [x in EffectName]: number; +}; + +const PanEffect: EffectDescriptor< + true, + "pan", + { leftGain: GainNode; rightGain: GainNode } +> = { + name: "pan", + initial: 0, + minimum: -100, + maximum: 100, + isPatch: true, + makeNodes() { + const aCtx = Sound.audioContext; + const input = aCtx.createGain(); + const leftGain = aCtx.createGain(); + const rightGain = aCtx.createGain(); + const channelMerger = aCtx.createChannelMerger(2); + const output = channelMerger; + input.connect(leftGain); + input.connect(rightGain); + leftGain.connect(channelMerger, 0, 0); + rightGain.connect(channelMerger, 0, 1); + return { input, output, leftGain, rightGain, channelMerger }; + }, + set(value, { leftGain, rightGain }) { + const p = (value + 100) / 200; + const leftVal = Math.cos((p * Math.PI) / 2); + const rightVal = Math.sin((p * Math.PI) / 2); + const { currentTime } = Sound.audioContext; + const { decayWait, decayDuration } = EffectChain; + leftGain.gain.setTargetAtTime( + leftVal, + currentTime + decayWait, + decayDuration + ); + rightGain.gain.setTargetAtTime( + rightVal, + currentTime + decayWait, + decayDuration + ); + }, +} as const; + +const PitchEffect: EffectDescriptor = { + name: "pitch", + initial: 0, + isPatch: false, + set(value, sound) { + const interval = value / 10; + const ratio = Math.pow(2, interval / 12); + sound.setPlaybackRate(ratio); + }, +} as const; + +const VolumeEffect: EffectDescriptor = { + name: "volume", + initial: 100, + minimum: 0, + maximum: 100, + resetOnStart: false, + resetOnClone: true, + isPatch: true, + makeNodes() { + const node = Sound.audioContext.createGain(); + return { + input: node, + output: node, + node, + }; + }, + set(value, { node }) { + node.gain.linearRampToValueAtTime( + value / 100, + Sound.audioContext.currentTime + EffectChain.decayDuration + ); + }, +} as const; + +const effectDescriptors = [PanEffect, PitchEffect, VolumeEffect] as const; +type EffectName = typeof effectDescriptors[number]["name"]; + +type EffectChainConfig = { getNonPatchSoundList: () => Sound[] }; + export class EffectChain { // The code in this class is functionally comparable to the class of the same // name in the scratch-audio library, but is completely rewritten and follows @@ -211,9 +367,19 @@ export class EffectChain { // a portable way to store the effect chain, independent of the audio sources // it affects. - constructor(config) { + public inputNode: AudioNode; + private getNonPatchSoundList: () => Sound[]; + private effectValues!: Record; + private effectNodes: { + [T in typeof effectDescriptors[number] as T["name"]]?: ReturnType< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends PatchDescriptor ? T["makeNodes"] : never + >; + }; + private target?: AudioNode; + + public constructor(config: EffectChainConfig) { const { getNonPatchSoundList } = config; - this.config = config; this.inputNode = Sound.audioContext.createGain(); @@ -232,7 +398,7 @@ export class EffectChain { this.getNonPatchSoundList = getNonPatchSoundList; } - resetToInitial() { + public resetToInitial(): void { // Note: some effects won't be reset by this function, except for when they // are set for the first time (i.e. when the EffectChain is instantiated). // Look for the "reset: false" flag in the effect descriptor list. @@ -241,8 +407,8 @@ export class EffectChain { if (this.effectValues) { for (const [name, initialValue] of Object.entries( EffectChain.getInitialEffectValues() - )) { - if (EffectChain.getEffectDescriptor(name).reset !== false) { + ) as [EffectName, number][]) { + if (EffectChain.getEffectDescriptor(name).resetOnStart !== false) { this.setEffectValue(name, initialValue); } } @@ -251,7 +417,7 @@ export class EffectChain { } } - updateAudioEffect(name) { + private updateAudioEffect(name: EffectName): void { const descriptor = EffectChain.getEffectDescriptor(name); if (!descriptor) { @@ -267,15 +433,22 @@ export class EffectChain { // who have existent nodes. This means we'll skip non-patch effects as // well as effects are set to their initial value. - let next = descriptor; + let nextDescriptor: EffectDescriptorBase = descriptor; do { - next = EffectChain.getNextEffectDescriptor(next.name); - } while (next && !this.effectNodes[next.name]); + nextDescriptor = EffectChain.getNextEffectDescriptor( + nextDescriptor.name + )!; + } while (nextDescriptor && !this.effectNodes[nextDescriptor.name]); - let previous = descriptor; + let previousDescriptor: EffectDescriptorBase = descriptor; do { - previous = EffectChain.getPreviousEffectDescriptor(previous.name); - } while (previous && !this.effectNodes[previous.name]); + previousDescriptor = EffectChain.getPreviousEffectDescriptor( + previousDescriptor.name + )!; + } while ( + previousDescriptor && + !this.effectNodes[previousDescriptor.name] + ); // If we have previous and next values available, they'll currently be // the corresponding descriptors. But we only ever need to access the @@ -283,12 +456,14 @@ export class EffectChain { // with the actual objects containing the effect's nodes here to simplify // later code. - if (next) { - next = this.effectNodes[next.name]; + let next; + if (nextDescriptor) { + next = this.effectNodes[nextDescriptor.name]; } - if (previous) { - next = this.effectNodes[previous.name]; + let previous; + if (previousDescriptor) { + previous = this.effectNodes[previousDescriptor.name]; } // If there is no preceding or following effect which has existent nodes, @@ -321,10 +496,13 @@ export class EffectChain { // node as both its input and output.) Other effects are more complex. // The code in this block controls the actual chaining behavior of // EffectChain, assuring that all effects form a clean chain. - let nodes = this.effectNodes[descriptor.name]; + let nodes = this.effectNodes[descriptor.name]!; if (!nodes && value !== descriptor.initial) { nodes = descriptor.makeNodes(); - this.effectNodes[descriptor.name] = nodes; + // The "as any" cast is needed because TypeScript can't infer that the + // descriptor's name determines the type of its nodes + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + this.effectNodes[descriptor.name] = nodes as any; // Connect the previous effect, or, if there is none, the EffectChain // input, to this effect. Also disconnect it from whatever it was @@ -370,7 +548,10 @@ export class EffectChain { delete this.effectNodes[name]; } } else { - descriptor.set(value, nodes); + // The "as any" cast is needed because TypeScript can't infer that the + // descriptor's name determines the type of its nodes + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + descriptor.set(value, nodes as any); } } else { // Non-"patch" effects operate directly on Sound objects, accessing @@ -382,7 +563,7 @@ export class EffectChain { } } - connect(target) { + public connect(target: AudioNode): void { this.target = target; // All the code here is basically the same as what's written in @@ -390,13 +571,17 @@ export class EffectChain { // disconnect the final output in the chain - which may be the input // node - and then connect it to the newly specified target. - let last = EffectChain.getLastEffectDescriptor(); + let lastDescriptor: EffectDescriptorBase = + EffectChain.getLastEffectDescriptor(); do { - last = EffectChain.getPreviousEffectDescriptor(last.name); - } while (last && !this.effectNodes[last.name]); - - if (last) { - last = this.effectNodes[last.name]; + lastDescriptor = EffectChain.getPreviousEffectDescriptor( + lastDescriptor.name + )!; + } while (lastDescriptor && !this.effectNodes[lastDescriptor.name]); + + let last; + if (lastDescriptor) { + last = this.effectNodes[lastDescriptor.name]!; } else { last = { output: this.inputNode }; } @@ -405,7 +590,10 @@ export class EffectChain { last.output.connect(target); } - setEffectValue(name, value) { + public setEffectValue( + name: EffectName, + value: number | string | boolean + ): void { value = Number(value); if ( name in this.effectValues && @@ -418,7 +606,10 @@ export class EffectChain { } } - changeEffectValue(name, value) { + private changeEffectValue( + name: EffectName, + value: number | string | boolean + ): void { value = Number(value); if (name in this.effectValues && !isNaN(value) && value !== 0) { this.effectValues[name] += value; @@ -427,79 +618,101 @@ export class EffectChain { } } - clampEffectValue(name) { + private clampEffectValue(name: EffectName): void { // Not all effects are clamped (pitch, for example); it's also possible to // specify only a minimum or maximum bound, instead of both. const descriptor = EffectChain.getEffectDescriptor(name); let value = this.effectValues[name]; - if ("minimum" in descriptor && value < descriptor.minimum) { + if (typeof descriptor.minimum === "number" && value < descriptor.minimum) { value = descriptor.minimum; - } else if ("maximum" in descriptor && value > descriptor.maximum) { + } else if ( + typeof descriptor.maximum === "number" && + value > descriptor.maximum + ) { value = descriptor.maximum; } this.effectValues[name] = value; } - getEffectValue(name) { + public getEffectValue(name: EffectName): number { return this.effectValues[name] || 0; } - clone(newConfig) { + public clone(newConfig: EffectChainConfig): EffectChain { const newEffectChain = new EffectChain( - Object.assign({}, this.config, newConfig) + Object.assign( + { + getNonPatchSoundList: this.getNonPatchSoundList, + }, + newConfig + ) ); - for (const [name, value] of Object.entries(this.effectValues)) { + for (const [name, value] of Object.entries(this.effectValues) as [ + EffectName, + number + ][]) { const descriptor = EffectChain.getEffectDescriptor(name); if (!descriptor.resetOnClone) { newEffectChain.setEffectValue(name, value); } } - newEffectChain.connect(this.target); + if (this.target) newEffectChain.connect(this.target); return newEffectChain; } - applyToSound(sound) { + public applyToSound(sound: Sound): void { sound.connect(this.inputNode); - for (const [name, value] of Object.entries(this.effectValues)) { + for (const [name, value] of Object.entries(this.effectValues) as [ + EffectName, + number + ][]) { const descriptor = EffectChain.getEffectDescriptor(name); if (!descriptor.isPatch) { - descriptor.set(value, sound); + (descriptor as PatchlessDescriptor).set(value, sound); } } } - isTargetOf(sound) { + public isTargetOf(sound: Sound): boolean { return sound.isConnectedTo(this.inputNode); } - static getInitialEffectValues() { + private static getInitialEffectValues(): Record { // This would be an excellent place to use Object.fromEntries, but that // function has been implemented in only the latest of a few modern // browsers. :P - const initials = {}; + const initials: Partial> = {}; for (const { name, initial } of this.effectDescriptors) { initials[name] = initial; } - return initials; + return initials as Record; } - static getEffectDescriptor(name) { - return this.effectDescriptors.find(descriptor => descriptor.name === name); + private static getEffectDescriptor( + name: EffectName + ): typeof EffectChain["effectDescriptors"][number] { + // We know this is non-null because this.effectDescriptors has every effect descriptor in it. + // TODO: use a Record? + return this.effectDescriptors.find( + (descriptor) => descriptor.name === name + )!; } - static getFirstEffectDescriptor() { + private static getFirstEffectDescriptor(): typeof effectDescriptors[number] { return this.effectDescriptors[0]; } - static getLastEffectDescriptor() { + private static getLastEffectDescriptor(): typeof effectDescriptors[number] { return this.effectDescriptors[this.effectDescriptors.length - 1]; } - static getNextEffectDescriptor(name) { + private static getNextEffectDescriptor( + name: EffectName + ): typeof effectDescriptors[number] | undefined { // .find() provides three values to its passed function: the value of the // current item, that item's index, and the array on which .find() is // operating. In this case, we're only concerned with the index. @@ -514,7 +727,9 @@ export class EffectChain { .find((_, i) => this.effectDescriptors[i].name === name); } - static getPreviousEffectDescriptor(name) { + private static getPreviousEffectDescriptor( + name: EffectName + ): typeof effectDescriptors[number] | undefined { // This function's a little simpler, since it doesn't involve shifting the // list. We still use slice(), but this time simply to cut off the last // item; that item will never come before any other, after all. We search @@ -526,130 +741,41 @@ export class EffectChain { .slice(0, -1) .find((_, i) => this.effectDescriptors[i + 1].name === name); } -} -// These are constant values which can be affected to tweak the way effects -// are applied. They match the values used in Scratch 3.0. -EffectChain.decayDuration = 0.025; -EffectChain.decayWait = 0.05; + // These are constant values which can be affected to tweak the way effects + // are applied. They match the values used in Scratch 3.0. + public static decayDuration = 0.025; + public static decayWait = 0.05; -// Instead of creating a basic Effect class and then implementing a subclass -// for each effect type, we use a simplified object-descriptor style. -// The makeNodes() function returns an object which is passed on to set(), so -// that effects are able to access a variety of nodes (or other values, if -// necessary) required to execute the desired effect. -// -// The code in makeNodes as well as the general definition for each effect is -// all graciously based on LLK's scratch-audio library. -// -// The initial value of an effect should always be the value at which the -// sound is not affected at all - i.e, it would be the same if the effect -// nodes were completely disconnected from the chain or otherwise had never -// been applied. This allows for clean discarding of effect nodes when returned -// to the initial value. -// -// The order of this array matches AudioEngine's effects list in scratch-audio. -// Earlier in the list is closer to the EffectChain input node; later is closer -// to its target (output). Note that a non-"patch" effect's position in the -// array has no bearing on effect behavior, since it isn't part of the chain -// system. -// -// Note that this descriptor list is fairly easy to build on, if we'd like to -// add more audio effects in the future. (Scratch used to have more, but they -// were removed - see commit ff6cd4a - because they depended on an external -// library and were too processor-intensive to support on some devices.) -EffectChain.effectDescriptors = [ - { - name: "pan", - initial: 0, - minimum: -100, - maximum: 100, - isPatch: true, - makeNodes() { - const aCtx = Sound.audioContext; - const input = aCtx.createGain(); - const leftGain = aCtx.createGain(); - const rightGain = aCtx.createGain(); - const channelMerger = aCtx.createChannelMerger(2); - const output = channelMerger; - input.connect(leftGain); - input.connect(rightGain); - leftGain.connect(channelMerger, 0, 0); - rightGain.connect(channelMerger, 0, 1); - return { input, output, leftGain, rightGain, channelMerger }; - }, - set(value, { input, output, leftGain, rightGain }) { - const p = (value + 100) / 200; - const leftVal = Math.cos((p * Math.PI) / 2); - const rightVal = Math.sin((p * Math.PI) / 2); - const { currentTime } = Sound.audioContext; - const { decayWait, decayDuration } = EffectChain; - leftGain.gain.setTargetAtTime( - leftVal, - currentTime + decayWait, - decayDuration - ); - rightGain.gain.setTargetAtTime( - rightVal, - currentTime + decayWait, - decayDuration - ); - } - }, - { - name: "pitch", - initial: 0, - isPatch: false, - set(value, sound) { - const interval = value / 10; - const ratio = Math.pow(2, interval / 12); - sound.setPlaybackRate(ratio); - } - }, - { - name: "volume", - initial: 100, - minimum: 0, - maximum: 100, - resetOnStart: false, - resetOnClone: true, - isPatch: true, - makeNodes() { - const node = Sound.audioContext.createGain(); - return { - input: node, - output: node, - node - }; - }, - set(value, { node }) { - node.gain.linearRampToValueAtTime( - value / 100, - Sound.audioContext.currentTime + EffectChain.decayDuration - ); - } - } -]; + public static effectDescriptors = effectDescriptors; +} -export class AudioEffectMap { +export class AudioEffectMap implements Effects { // This class provides a simple interface for setting and getting audio // effects stored on an EffectChain, similar to EffectMap (that class being // for graphic effects). It takes an EffectChain and automatically generates // properties according to the names of the effect descriptors, acting with // the EffectChain's API when accessed. + private effectChain: EffectChain; + + // TypeScript can't infer these + public pan!: number; + public pitch!: number; + public volume!: number; - constructor(effectChain) { + public constructor(effectChain: EffectChain) { this.effectChain = effectChain; for (const { name } of EffectChain.effectDescriptors) { Object.defineProperty(this, name, { get: () => effectChain.getEffectValue(name), - set: value => effectChain.setEffectValue(name, value) + set: (value: string | number | boolean) => + effectChain.setEffectValue(name, value), }); } } - clear() { + public clear(): void { this.effectChain.resetToInitial(); } } diff --git a/src/Sprite.js b/src/Sprite.ts similarity index 55% rename from src/Sprite.js rename to src/Sprite.ts index 62aff76..1a5f760 100644 --- a/src/Sprite.js +++ b/src/Sprite.ts @@ -1,26 +1,54 @@ -import Color from "./Color.js"; -import Trigger from "./Trigger.js"; -import Sound, { EffectChain, AudioEffectMap } from "./Sound.js"; +import Color from "./Color"; +import Trigger from "./Trigger"; +import Sound, { EffectChain, AudioEffectMap } from "./Sound"; +import Costume from "./Costume"; +import type { Mouse } from "./Input"; +import type Project from "./Project"; +import type Watcher from "./Watcher"; +import type { Yielding } from "./lib/yielding"; + +import { effectNames } from "./renderer/effectInfo"; + +type Effects = { + [x in typeof effectNames[number]]: number; +}; -import { effectNames } from "./renderer/effectInfo.js"; // This is a wrapper to allow the enabled effects in a sprite to be used as a Map key. // By setting an effect, the bitmask is updated as well. // This allows the bitmask to be used to uniquely identify a set of enabled effects. -class _EffectMap { - constructor() { +export class _EffectMap implements Effects { + public _bitmask: number; + private _effectValues: Record; + // TODO: TypeScript can't automatically infer these + public color!: number; + public fisheye!: number; + public whirl!: number; + public pixelate!: number; + public mosaic!: number; + public brightness!: number; + public ghost!: number; + + public constructor() { this._bitmask = 0; - this._effectValues = {}; + this._effectValues = { + color: 0, + fisheye: 0, + whirl: 0, + pixelate: 0, + mosaic: 0, + brightness: 0, + ghost: 0, + }; for (let i = 0; i < effectNames.length; i++) { const effectName = effectNames[i]; - this._effectValues[effectName] = 0; Object.defineProperty(this, effectName, { get: () => { return this._effectValues[effectName]; }, - set: val => { + set: (val: number) => { this._effectValues[effectName] = val; if (val === 0) { @@ -30,31 +58,62 @@ class _EffectMap { // Otherwise, set its bit to 1. this._bitmask = this._bitmask | (1 << i); } - } + }, }); } } - _clone() { + public _clone(): _EffectMap { const m = new _EffectMap(); - for (const effectName of Object.keys(this._effectValues)) { + for (const effectName of Object.keys( + this._effectValues + ) as (keyof typeof this._effectValues)[]) { m[effectName] = this[effectName]; } return m; } - clear() { - for (const effectName of Object.keys(this._effectValues)) { + public clear(): void { + for (const effectName of Object.keys( + this._effectValues + ) as (keyof typeof this._effectValues)[]) { this._effectValues[effectName] = 0; } this._bitmask = 0; } } -class SpriteBase { - constructor(initialConditions, vars = {}) { - this._project = null; +export type SpeechBubbleStyle = "say" | "think"; + +export type SpeechBubble = { + text: string; + style: SpeechBubbleStyle; + timeout: number | null; +}; + +type InitialConditions = { + costumeNumber: number; + layerOrder?: number; +}; + +abstract class SpriteBase { + protected _project!: Project; + protected _costumeNumber: number; + protected _layerOrder: number; + public triggers: Trigger[]; + public watchers: Partial>; + protected costumes: Costume[]; + protected sounds: Sound[]; + + protected effectChain: EffectChain; + public effects: _EffectMap; + public audioEffects: AudioEffectMap; + + protected _vars: object; + + public constructor(initialConditions: InitialConditions, vars = {}) { + // TODO: pass project in here, ideally const { costumeNumber, layerOrder = 0 } = initialConditions; this._costumeNumber = costumeNumber; this._layerOrder = layerOrder; @@ -65,7 +124,7 @@ class SpriteBase { this.sounds = []; this.effectChain = new EffectChain({ - getNonPatchSoundList: this.getSoundsPlayedByMe.bind(this) + getNonPatchSoundList: this.getSoundsPlayedByMe.bind(this), }); this.effectChain.connect(Sound.audioContext.destination); @@ -75,37 +134,47 @@ class SpriteBase { this._vars = vars; } - getSoundsPlayedByMe() { - return this.sounds.filter(sound => this.effectChain.isTargetOf(sound)); + protected getSoundsPlayedByMe(): Sound[] { + return this.sounds.filter((sound) => this.effectChain.isTargetOf(sound)); } - get stage() { + public get stage(): Stage { return this._project.stage; } - get sprites() { + public get sprites(): Partial> { return this._project.sprites; } - get vars() { + public get vars(): object { return this._vars; } - get costumeNumber() { + public get costumeNumber(): number { return this._costumeNumber; } - set costumeNumber(number) { - this._costumeNumber = this.wrapClamp(number, 1, this.costumes.length); - if (this.fireBackdropChanged) this.fireBackdropChanged(); + public set costumeNumber(number) { + if (Number.isFinite(number)) { + this._costumeNumber = this.wrapClamp(number, 1, this.costumes.length); + } else { + this._costumeNumber = 0; + } } - set costume(costume) { + public set costume(costume: number | string | Costume) { + if (costume instanceof Costume) { + const costumeIndex = this.costumes.indexOf(costume); + if (costumeIndex > -1) { + this.costumeNumber = costumeIndex + 1; + } + } if (typeof costume === "number") { this.costumeNumber = costume; + return; } if (typeof costume === "string") { - const index = this.costumes.findIndex(c => c.name === costume); + const index = this.costumes.findIndex((c) => c.name === costume); if (index > -1) { this.costumeNumber = index + 1; } else { @@ -141,7 +210,7 @@ class SpriteBase { } default: { - if (!(isNaN(costume) || costume.trim().length === 0)) { + if (!Number.isNaN(Number(costume)) && costume.trim().length !== 0) { this.costumeNumber = Number(costume); } } @@ -150,52 +219,36 @@ class SpriteBase { } } - get costume() { + public get costume(): Costume { return this.costumes[this.costumeNumber - 1]; } - moveAhead(value = Infinity) { - if (typeof value === "number") { - this._project.changeSpriteLayer(this, value); - } else { - this._project.changeSpriteLayer(this, 1, value); - } - } - - moveBehind(value = Infinity) { - if (typeof value === "number") { - this._project.changeSpriteLayer(this, -value); - } else { - this._project.changeSpriteLayer(this, -1, value); - } - } - - degToRad(deg) { + public degToRad(deg: number): number { return (deg * Math.PI) / 180; } - radToDeg(rad) { + public radToDeg(rad: number): number { return (rad * 180) / Math.PI; } - degToScratch(deg) { + public degToScratch(deg: number): number { return -deg + 90; } - scratchToDeg(scratchDir) { + public scratchToDeg(scratchDir: number): number { return -scratchDir + 90; } - radToScratch(rad) { + public radToScratch(rad: number): number { return this.degToScratch(this.radToDeg(rad)); } - scratchToRad(scratchDir) { + public scratchToRad(scratchDir: number): number { return this.degToRad(this.scratchToDeg(scratchDir)); } // From scratch-vm's math-util. - scratchTan(angle) { + public scratchTan(angle: number): number { angle = angle % 360; switch (angle) { case -270: @@ -210,7 +263,7 @@ class SpriteBase { } // Wrap rotation from -180 to 180. - normalizeDeg(deg) { + public normalizeDeg(deg: number): number { // This is a pretty big math expression, but it's necessary because in JavaScript, // the % operator means "remainder", not "modulo", and so negative numbers won't "wrap around". // See https://web.archive.org/web/20090717035140if_/javascript.about.com/od/problemsolving/a/modulobug.htm @@ -222,13 +275,13 @@ class SpriteBase { // wrapClamp(0, 1, 5) == 5 // wrapClamp(-11, -10, 6) == 6 // Borrowed from scratch-vm (src/util/math-util.js) - wrapClamp(n, min, max) { - const range = (max - min) + 1; - return n - (Math.floor((n - min) / range) * range); + public wrapClamp(n: number, min: number, max: number): number { + const range = max - min + 1; + return n - Math.floor((n - min) / range) * range; } // Given a generator function, return a version of it that runs in "warp mode" (no yields). - warp(procedure) { + public warp(procedure: GeneratorFunction): (...args: unknown[]) => void { const bound = procedure.bind(this); return (...args) => { const inst = bound(...args); @@ -236,7 +289,8 @@ class SpriteBase { }; } - random(a, b) { + // TODO: this should also take strings so rand("0.0", "1.0") returns a random float like Scratch + public random(a: number, b: number): number { const min = Math.min(a, b); const max = Math.max(a, b); if (min % 1 === 0 && max % 1 === 0) { @@ -245,31 +299,31 @@ class SpriteBase { return Math.random() * (max - min) + min; } - *wait(secs) { - let endTime = new Date(); + public *wait(secs: number): Yielding { + const endTime = new Date(); endTime.setMilliseconds(endTime.getMilliseconds() + secs * 1000); while (new Date() < endTime) { yield; } } - get mouse() { + public get mouse(): Mouse { return this._project.input.mouse; } - keyPressed(name) { + public keyPressed(name: string): boolean { return this._project.input.keyPressed(name); } - get timer() { + public get timer(): number { return this._project.timer; } - restartTimer() { + public restartTimer(): void { this._project.restartTimer(); } - *startSound(soundName) { + public *startSound(soundName: string): Yielding { const sound = this.getSound(soundName); if (sound) { this.effectChain.applyToSound(sound); @@ -277,7 +331,7 @@ class SpriteBase { } } - *playSoundUntilDone(soundName) { + public *playSoundUntilDone(soundName: string): Yielding { const sound = this.getSound(soundName); if (sound) { sound.connect(this.effectChain.inputNode); @@ -286,31 +340,31 @@ class SpriteBase { } } - getSound(soundName) { + public getSound(soundName: string): Sound | undefined { if (typeof soundName === "number") { return this.sounds[(soundName - 1) % this.sounds.length]; } else { - return this.sounds.find(s => s.name === soundName); + return this.sounds.find((s) => s.name === soundName); } } - stopAllSounds() { + public stopAllSounds(): void { this._project.stopAllSounds(); } - stopAllOfMySounds() { + public stopAllOfMySounds(): void { for (const sound of this.sounds) { sound.stop(); } } - broadcast(name) { - return this._project.fireTrigger(Trigger.BROADCAST, { name }); + public broadcast(name: string): Promise { + return this._project.fireTrigger(Trigger.broadcastReceived, { name }); } - *broadcastAndWait(name) { + public *broadcastAndWait(name: string): Yielding { let running = true; - this.broadcast(name).then(() => { + void this.broadcast(name).then(() => { running = false; }); @@ -319,33 +373,29 @@ class SpriteBase { } } - clearPen() { + public clearPen(): void { this._project.renderer.clearPen(); } - *askAndWait(question) { - if (this._speechBubble) { - this.say(""); - } - + public *askAndWait(question: string): Yielding { let done = false; - this._project.askAndWait(question).then(() => { + void this._project.askAndWait(question).then(() => { done = true; }); while (!done) yield; } - get answer() { + public get answer(): string | null { return this._project.answer; } - get loudness() { + public get loudness(): number { return this._project.loudness; } - toNumber(value) { - if (typeof value === 'number') { + public toNumber(value: unknown): number { + if (typeof value === "number") { if (isNaN(value)) { return 0; } @@ -359,13 +409,13 @@ class SpriteBase { return n; } - toBoolean(value) { - if (typeof value === 'boolean') { + public toBoolean(value: unknown): boolean { + if (typeof value === "boolean") { return value; } - if (typeof value === 'string') { - if (value === '' || value === '0' || value.toLowerCase() === 'false') { + if (typeof value === "string") { + if (value === "" || value === "0" || value.toLowerCase() === "false") { return false; } return true; @@ -374,37 +424,37 @@ class SpriteBase { return Boolean(value); } - toString(value) { + public toString(value: unknown): string { return String(value); } - stringIncludes(string, substring) { + public stringIncludes(string: string, substring: string): boolean { return string.toLowerCase().includes(substring.toLowerCase()); } - arrayIncludes(array, value) { - return array.some(item => this.compare(item, value) === 0); + public arrayIncludes(array: T[], value: T): boolean { + return array.some((item) => this.compare(item, value) === 0); } - letterOf(string, index) { + public letterOf(string: string, index: number): string { if (index < 0 || index >= string.length) { return ""; } return string[index]; } - itemOf(array, index) { + public itemOf(array: T[], index: number): T | "" { if (index < 0 || index >= array.length) { return ""; } return array[index]; } - indexInArray(array, value) { - return array.findIndex(item => this.compare(item, value) === 0); + public indexInArray(array: T[], value: T): number { + return array.findIndex((item) => this.compare(item, value) === 0); } - compare(v1, v2) { + public compare(v1: unknown, v2: unknown): number { if (v1 === v2) { return 0; } @@ -418,9 +468,15 @@ class SpriteBase { return 0; } - if (n1 === 0 && (v1 === null || typeof v1 === 'string' && v1.trim().length === 0)) { + if ( + n1 === 0 && + (v1 === null || (typeof v1 === "string" && v1.trim().length === 0)) + ) { n1 = NaN; - } else if (n2 === 0 && (v2 === null || typeof v2 === 'string' && v2.trim().length === 0)) { + } else if ( + n2 === 0 && + (v2 === null || (typeof v2 === "string" && v2.trim().length === 0)) + ) { n2 = NaN; } @@ -441,9 +497,40 @@ class SpriteBase { } } +type RotationStyle = + typeof Sprite["RotationStyle"][keyof typeof Sprite["RotationStyle"]]; + +type SpriteInitialConditions = { + x: number; + y: number; + direction: number; + rotationStyle?: RotationStyle; + costumeNumber: number; + size: number; + visible: boolean; + penDown?: boolean; + penSize?: number; + penColor?: Color; +}; + export class Sprite extends SpriteBase { - constructor(initialConditions, ...args) { - super(initialConditions, ...args); + private _x: number; + private _y: number; + private _direction: number; + public rotationStyle: RotationStyle; + public size: number; + public visible: boolean; + + private parent: this | null; + public clones: this[]; + + private _penDown: boolean; + public penSize: number; + private _penColor: Color; + public _speechBubble?: SpeechBubble; + + public constructor(initialConditions: SpriteInitialConditions, vars = {}) { + super(initialConditions, vars); const { x, @@ -455,7 +542,7 @@ export class Sprite extends SpriteBase { visible, penDown, penSize, - penColor + penColor, } = initialConditions; this._x = x; @@ -476,20 +563,26 @@ export class Sprite extends SpriteBase { this._speechBubble = { text: "", style: "say", - timeout: null + timeout: null, }; } - createClone() { + public *askAndWait(question: string): Yielding { + if (this._speechBubble) { + this.say(""); + } + + yield* super.askAndWait(question); + } + + public createClone(): void { const clone = Object.assign( - Object.create(Object.getPrototypeOf(this)), + Object.create(Object.getPrototypeOf(this) as object) as this, this ); clone._project = this._project; - clone.triggers = this.triggers.map( - trigger => new Trigger(trigger.trigger, trigger.options, trigger._script) - ); + clone.triggers = this.triggers.map((trigger) => trigger.clone()); clone.costumes = this.costumes; clone.sounds = this.sounds; clone._vars = Object.assign({}, this._vars); @@ -497,19 +590,20 @@ export class Sprite extends SpriteBase { clone._speechBubble = { text: "", style: "say", - timeout: null + timeout: null, }; clone.effects = this.effects._clone(); // Clones inherit audio effects from the original sprite, for some reason. // Couldn't explain it, but that's the behavior in Scratch 3.0. + // eslint-disable-next-line @typescript-eslint/no-this-alias let original = this; while (original.parent) { original = original.parent; } clone.effectChain = original.effectChain.clone({ - getNonPatchSoundList: clone.getSoundsPlayedByMe.bind(clone) + getNonPatchSoundList: clone.getSoundsPlayedByMe.bind(clone), }); // Make a new audioEffects interface which acts on the cloned effect chain. @@ -520,37 +614,37 @@ export class Sprite extends SpriteBase { this.clones.push(clone); // Trigger CLONE_START: - const triggers = clone.triggers.filter(tr => + const triggers = clone.triggers.filter((tr) => tr.matches(Trigger.CLONE_START, {}, clone) ); - this._project._startTriggers( - triggers.map(trigger => ({ trigger, target: clone })) + void this._project._startTriggers( + triggers.map((trigger) => ({ trigger, target: clone })) ); } - deleteThisClone() { + public deleteThisClone(): void { if (this.parent === null) return; - this.parent.clones = this.parent.clones.filter(clone => clone !== this); + this.parent.clones = this.parent.clones.filter((clone) => clone !== this); this._project.runningTriggers = this._project.runningTriggers.filter( ({ target }) => target !== this ); } - andClones() { - return [this, ...this.clones.flatMap(clone => clone.andClones())]; + public andClones(): this[] { + return [this, ...this.clones.flatMap((clone) => clone.andClones())]; } - get direction() { + public get direction(): number { return this._direction; } - set direction(dir) { + public set direction(dir) { this._direction = this.normalizeDeg(dir); } - goto(x, y) { + public goto(x: number, y: number): void { if (x === this.x && y === this.y) return; if (this.penDown) { @@ -566,23 +660,23 @@ export class Sprite extends SpriteBase { this._y = y; } - get x() { + public get x(): number { return this._x; } - set x(x) { + public set x(x) { this.goto(x, this._y); } - get y() { + public get y(): number { return this._y; } - set y(y) { + public set y(y) { this.goto(this._x, y); } - move(dist) { + public move(dist: number): void { const moveDir = this.scratchToRad(this.direction); this.goto( @@ -591,8 +685,9 @@ export class Sprite extends SpriteBase { ); } - *glide(seconds, x, y) { - const interpolate = (a, b, t) => a + (b - a) * t; + public *glide(seconds: number, x: number, y: number): Yielding { + const interpolate = (a: number, b: number, t: number): number => + a + (b - a) * t; const startTime = new Date(); const startX = this._x; @@ -600,17 +695,33 @@ export class Sprite extends SpriteBase { let t; do { - t = (new Date() - startTime) / (seconds * 1000); + t = (new Date().getTime() - startTime.getTime()) / (seconds * 1000); this.goto(interpolate(startX, x, t), interpolate(startY, y, t)); yield; } while (t < 1); } - get penDown() { + public moveAhead(value: number | Sprite = Infinity): void { + if (typeof value === "number") { + this._project.changeSpriteLayer(this, value); + } else { + this._project.changeSpriteLayer(this, 1, value); + } + } + + public moveBehind(value: number | Sprite = Infinity): void { + if (typeof value === "number") { + this._project.changeSpriteLayer(this, -value); + } else { + this._project.changeSpriteLayer(this, -1, value); + } + } + + public get penDown(): boolean { return this._penDown; } - set penDown(penDown) { + public set penDown(penDown) { if (penDown) { this._project.renderer.penLine( { x: this.x, y: this.y }, @@ -622,25 +733,28 @@ export class Sprite extends SpriteBase { this._penDown = penDown; } - get penColor() { + public get penColor(): Color { return this._penColor; } - set penColor(color) { + public set penColor(color: unknown) { if (color instanceof Color) { this._penColor = color; } else { console.error( - `${color} is not a valid penColor. Try using the Color class!` + `${String(color)} is not a valid penColor. Try using the Color class!` ); } } - stamp() { + public stamp(): void { this._project.renderer.stamp(this); } - touching(target, fast = false) { + public touching( + target: "mouse" | "edge" | Sprite | Stage, + fast = false + ): boolean { if (typeof target === "string") { switch (target) { case "mouse": @@ -648,7 +762,7 @@ export class Sprite extends SpriteBase { this, { x: this.mouse.x, - y: this.mouse.y + y: this.mouse.y, }, fast ); @@ -665,6 +779,7 @@ export class Sprite extends SpriteBase { } default: console.error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Cannot find target "${target}" in "touching". Did you mean to pass a sprite class instead?` ); return false; @@ -676,9 +791,10 @@ export class Sprite extends SpriteBase { return this._project.renderer.checkSpriteCollision(this, target, fast); } - colorTouching(color, target) { + public colorTouching(color: Color, target: Sprite | Stage): boolean { if (typeof target === "string") { console.error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Cannot find target "${target}" in "touchingColor". Did you mean to pass a sprite class instead?` ); return false; @@ -686,6 +802,7 @@ export class Sprite extends SpriteBase { if (typeof color === "string") { console.error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Cannot find color "${color}" in "touchingColor". Did you mean to pass a Color instance instead?` ); return false; @@ -705,77 +822,97 @@ export class Sprite extends SpriteBase { } } - say(text) { - clearTimeout(this._speechBubble.timeout); + public say(text: string): void { + if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); this._speechBubble = { text: String(text), style: "say", timeout: null }; } - think(text) { - clearTimeout(this._speechBubble.timeout); + public think(text: string): void { + if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); this._speechBubble = { text: String(text), style: "think", timeout: null }; } - *sayAndWait(text, seconds) { - clearTimeout(this._speechBubble.timeout); + public *sayAndWait(text: string, seconds: number): Yielding { + if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); + const speechBubble: SpeechBubble = { text, style: "say", timeout: null }; let done = false; - const timeout = setTimeout(() => { - this._speechBubble.text = ""; - this.timeout = null; + const timeout = window.setTimeout(() => { + speechBubble.text = ""; + speechBubble.timeout = null; done = true; }, seconds * 1000); - this._speechBubble = { text, style: "say", timeout }; + speechBubble.timeout = timeout; + this._speechBubble = speechBubble; while (!done) yield; } - *thinkAndWait(text, seconds) { - clearTimeout(this._speechBubble.timeout); + public *thinkAndWait(text: string, seconds: number): Yielding { + if (this._speechBubble?.timeout) clearTimeout(this._speechBubble.timeout); + const speechBubble: SpeechBubble = { text, style: "think", timeout: null }; let done = false; - const timeout = setTimeout(() => { - this._speechBubble.text = ""; - this.timeout = null; + const timeout = window.setTimeout(() => { + speechBubble.text = ""; + speechBubble.timeout = null; done = true; }, seconds * 1000); - this._speechBubble = { text, style: "think", timeout }; + speechBubble.timeout = timeout; + this._speechBubble = speechBubble; while (!done) yield; } + + public static RotationStyle = Object.freeze({ + ALL_AROUND: Symbol("ALL_AROUND"), + LEFT_RIGHT: Symbol("LEFT_RIGHT"), + DONT_ROTATE: Symbol("DONT_ROTATE"), + }); } -Sprite.RotationStyle = Object.freeze({ - ALL_AROUND: Symbol("ALL_AROUND"), - LEFT_RIGHT: Symbol("LEFT_RIGHT"), - DONT_ROTATE: Symbol("DONT_ROTATE") -}); +type StageInitialConditions = { + width?: number; + height?: number; +} & InitialConditions; export class Stage extends SpriteBase { - constructor(initialConditions, ...args) { - super(initialConditions, ...args); + public readonly width!: number; + public readonly height!: number; + public __counter: number; + + public constructor(initialConditions: StageInitialConditions, vars = {}) { + super(initialConditions, vars); // Use defineProperties to make these non-writable. // Changing the width and height of the stage after initialization isn't supported. Object.defineProperties(this, { width: { value: initialConditions.width || 480, - enumerable: true + enumerable: true, }, height: { value: initialConditions.height || 360, - enumerable: true - } + enumerable: true, + }, }); - this.name = "Stage"; - // For obsolete counter blocks. this.__counter = 0; } - fireBackdropChanged() { - return this._project.fireTrigger(Trigger.BACKDROP_CHANGED, { - backdrop: this.costume.name + public fireBackdropChanged(): Promise { + return this._project.fireTrigger(Trigger.backdropChanged, { + backdrop: this.costume.name, }); } + + public get costumeNumber(): number { + return super.costumeNumber; + } + + public set costumeNumber(number) { + super.costumeNumber = number; + void this.fireBackdropChanged(); + } } diff --git a/src/Trigger.js b/src/Trigger.js deleted file mode 100644 index fcfe512..0000000 --- a/src/Trigger.js +++ /dev/null @@ -1,99 +0,0 @@ -const GREEN_FLAG = Symbol("GREEN_FLAG"); -const KEY_PRESSED = Symbol("KEY_PRESSED"); -const BROADCAST = Symbol("BROADCAST"); -const CLICKED = Symbol("CLICKED"); -const CLONE_START = Symbol("CLONE_START"); -const LOUDNESS_GREATER_THAN = Symbol("LOUDNESS_GREATER_THAN"); -const TIMER_GREATER_THAN = Symbol("TIMER_GREATER_THAN"); -const BACKDROP_CHANGED = Symbol("BACKDROP_CHANGED"); - -export default class Trigger { - constructor(trigger, options, script) { - this.trigger = trigger; - - if (typeof script === "undefined") { - this.options = {}; - this._script = options; - } else { - this.options = options; - this._script = script; - } - - this.done = false; - this.stop = () => {}; - } - - get isEdgeActivated() { - return ( - this.trigger === TIMER_GREATER_THAN || - this.trigger === LOUDNESS_GREATER_THAN - ); - } - - // Evaluate the given trigger option, whether it's a value or a function that - // returns a value given a target - option(option, target) { - let triggerOption = this.options[option]; - // If the given option is a function, evaluate that function, passing in - // the target that we're evaluating the trigger for - if (typeof triggerOption === "function") { - return triggerOption(target); - } - return triggerOption; - } - - matches(trigger, options, target) { - if (this.trigger !== trigger) return false; - for (let option in options) { - if (this.option(option, target) !== options[option]) return false; - } - - return true; - } - - start(target) { - this.stop(); - - const boundScript = this._script.bind(target); - - this.done = false; - this._runningScript = boundScript(); - - return new Promise(resolve => { - this.stop = () => { - this.done = true; - resolve(); - }; - }); - } - - step() { - this.done = this._runningScript.next().done; - if (this.done) this.stop(); - } - - static get GREEN_FLAG() { - return GREEN_FLAG; - } - static get KEY_PRESSED() { - return KEY_PRESSED; - } - static get BROADCAST() { - return BROADCAST; - } - static get CLICKED() { - return CLICKED; - } - static get CLONE_START() { - return CLONE_START; - } - static get LOUDNESS_GREATER_THAN() { - return LOUDNESS_GREATER_THAN; - } - static get TIMER_GREATER_THAN() { - return TIMER_GREATER_THAN; - } - static get BACKDROP_CHANGED() { - return BACKDROP_CHANGED; - } -} diff --git a/src/Trigger.ts b/src/Trigger.ts new file mode 100644 index 0000000..c291a8e --- /dev/null +++ b/src/Trigger.ts @@ -0,0 +1,222 @@ +import type { Sprite, Stage } from "./Sprite"; + +type TriggerOption = + | number + | string + | boolean + | ((target: Sprite | Stage) => number | string | boolean); + +type TriggerOptions = Partial>; + +// TODO: Remove symbol property. This is for support with old-style triggers. +// A unique function serves as a valid distinguisher and reduces the overall +// type footprint. +export type TriggerCreator = + (( + optionsOrScript: TriggerOptions | GeneratorFunction, + script?: GeneratorFunction + ) => Trigger) + & {symbol: symbol}; + +export default class Trigger { + // TODO: Expose as TriggerCreator instead of symbol. + public trigger; + + private options: TriggerOptions; + private _script: GeneratorFunction; + private _runningScript: Generator | undefined; + public done: boolean; + private stop: () => void; + + protected static alertedDeprecatedSymbols: Set = new Set(); + + public constructor( + // TODO: Only accept TriggerCreator. + trigger: symbol | TriggerCreator, + optionsOrScript: TriggerOptions | GeneratorFunction, + script?: GeneratorFunction + ) { + if (typeof trigger === "function") { + this.trigger = trigger.symbol; + } else { + this.trigger = trigger; + } + + if (typeof script === "undefined") { + this.options = {}; + this._script = optionsOrScript as GeneratorFunction; + } else { + this.options = optionsOrScript as TriggerOptions; + this._script = script; + } + + // This is deliberately positioned after this.options is set. + if (typeof trigger === "symbol") { + this.warnDeprecatedConstructorSyntax(); + } + + this.done = false; + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.stop = () => {}; + } + + private warnDeprecatedConstructorSyntax() { + const symbol = this.trigger; + + if (Trigger.alertedDeprecatedSymbols.has(symbol)) { + return; + } else { + Trigger.alertedDeprecatedSymbols.add(symbol); + } + + const topMessage = `Trigger.${symbol.description} syntax is deprecated - it'll be removed in a future version of Leopard.`; + + const triggerCreatorName = Object.entries(Object.getOwnPropertyDescriptors(Trigger)) + .find(([_key, { value }]) => typeof value === "function" && value.symbol === symbol)?.[0]; + + if (!triggerCreatorName) { + console.warn(topMessage + '.'); + return; + } + + const stringifyOptions = () => + `{${ + Object.entries(this.options!) + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join(', ')}}`; + + const triggerSyntax = Object.keys(this.options).length + ? `Trigger.${triggerCreatorName}(${stringifyOptions()}, this.myScript)` + : `Trigger.${triggerCreatorName}(this.myScript)`; + + console.warn(`${topMessage}\nUse the new syntax: ${triggerSyntax}`); + } + + public get isEdgeActivated(): boolean { + return ( + this.trigger === Trigger.TIMER_GREATER_THAN || + this.trigger === Trigger.LOUDNESS_GREATER_THAN + ); + } + + // Evaluate the given trigger option, whether it's a value or a function that + // returns a value given a target + public option( + option: string, + target: Sprite | Stage + ): number | string | boolean | undefined { + const triggerOption = this.options[option]; + // If the given option is a function, evaluate that function, passing in + // the target that we're evaluating the trigger for + if (typeof triggerOption === "function") { + return triggerOption(target); + } + return triggerOption; + } + + public matches( + // TODO: Rework to not accept a symbol. Just compare to TriggerCreator. + trigger: TriggerCreator | symbol, + options?: Trigger["options"], + target?: Sprite | Stage + ): boolean { + if (options && !target) { + throw new Error("Expected target to check options against"); + } + + const triggerSymbol = (typeof trigger === "function" ? trigger.symbol : trigger); + if (this.trigger !== triggerSymbol) return false; + + for (const option in options) { + if (this.option(option, target!) !== options[option]) return false; + } + + return true; + } + + public start(target: Sprite | Stage): Promise { + this.stop(); + + this.done = false; + this._runningScript = this._script.call(target); + + return new Promise((resolve) => { + this.stop = (): void => { + this.done = true; + resolve(); + }; + }); + } + + public step(): void { + if (!this._runningScript) return; + this.done = !!this._runningScript.next().done; + if (this.done) this.stop(); + } + + public clone(): Trigger { + return new Trigger(this.trigger, this.options, this._script); + } + + /** + * Check if two TriggerCreators match. This interface is intended to be + * agnostic to the Trigger class' internals. + * + * Note: This is not for matching actual Trigger instances against a + * TriggerCreator. Use the trigger's own .matches() function, which accepts + * additional options relevant to that situation. + */ + public static matches( + // TODO: Rework to not accept symbols. Just compare both TriggerCreators. + triggerCreator1: TriggerCreator | symbol, + triggerCreator2: TriggerCreator | symbol, + ): boolean { + const symbol1 = (typeof triggerCreator1 === "symbol" ? triggerCreator1 : triggerCreator1.symbol); + const symbol2 = (typeof triggerCreator2 === "symbol" ? triggerCreator2 : triggerCreator2.symbol); + return symbol1 === symbol2; + } + + private static triggerCreatorHelper(symbolText: string): TriggerCreator { + const symbol = Symbol(symbolText); + const triggerCreator: TriggerCreator = function(optionsOrScript, script) { + return new Trigger(symbol, optionsOrScript, script); + }; + + triggerCreator.symbol = symbol; + return triggerCreator; + } + + /** + * Each property below doubles as a function to create a trigger and a unique + * object to match trigger instances against that class. For example: + * + * this.triggers = [Trigger.greenFlag(this.whenGreenFlagClicked)]; + * if (aTrigger.matches(Trigger.greenFlag)) ...; + * + * TODO: Remove symbol strings. + */ + public static readonly greenFlag = this.triggerCreatorHelper("GREEN_FLAG"); + public static readonly keyPressed = this.triggerCreatorHelper("KEY_PRESSED"); + public static readonly broadcastReceived = this.triggerCreatorHelper("BROADCAST"); + public static readonly clicked = this.triggerCreatorHelper("CLICKED"); + public static readonly startedAsClone = this.triggerCreatorHelper("CLONE_START"); + public static readonly loudnessGreaterThan = this.triggerCreatorHelper("LOUDNESS_GREATER_THAN"); + public static readonly timerGreaterThan = this.triggerCreatorHelper("TIMER_GREATER_THAN"); + public static readonly backdropChanged = this.triggerCreatorHelper("BACKDROP_CHANGED"); + public static readonly unreachable = this.triggerCreatorHelper("UNREACHABLE"); + + /** + * @deprecated + * Prefer accessing the properties above to create or match a trigger. + */ + public static readonly GREEN_FLAG = this.greenFlag.symbol; + public static readonly KEY_PRESSED = this.keyPressed.symbol; + public static readonly BROADCAST = this.broadcastReceived.symbol; + public static readonly CLICKED = this.clicked.symbol; + public static readonly CLONE_START = this.startedAsClone.symbol; + public static readonly LOUDNESS_GREATER_THAN = this.loudnessGreaterThan.symbol; + public static readonly TIMER_GREATER_THAN = this.timerGreaterThan.symbol; + public static readonly BACKDROP_CHANGED = this.backdropChanged.symbol; +} + +export type { TriggerOption, TriggerOptions }; diff --git a/src/Watcher.js b/src/Watcher.ts similarity index 63% rename from src/Watcher.js rename to src/Watcher.ts index bb9d5f5..389d82f 100644 --- a/src/Watcher.js +++ b/src/Watcher.ts @@ -1,8 +1,57 @@ import Color from "./Color"; +type WatcherValue = + | string + | number + | boolean + | null + | undefined + | (string | number | boolean | null | undefined)[]; + +type WatcherStyle = "normal" | "large" | "slider"; + +type WatcherOptions = { + value?: () => WatcherValue; + setValue?: (value: number) => void; + label: string; + style?: WatcherStyle; + visible?: boolean; + color?: Color; + step?: number; + x?: number; + y?: number; + width?: number; + height?: number; + min?: number; + max?: number; +}; + export default class Watcher { - constructor({ + public value: () => WatcherValue; + public setValue: (value: number) => void; + private _previousValue: unknown | symbol; + private color: Color; + private _label!: string; + private _x!: number; + private _y!: number; + private _width: number | undefined; + private _height: number | undefined; + private _min!: number; + private _max!: number; + private _step!: number; + private _style!: WatcherStyle; + private _visible!: boolean; + + private _dom!: { + node: HTMLElement; + label: HTMLElement; + value: HTMLElement; + slider: HTMLInputElement; + }; + + public constructor({ value = () => "", + // eslint-disable-next-line @typescript-eslint/no-empty-function setValue = () => {}, label, style = "normal", @@ -14,8 +63,8 @@ export default class Watcher { x = -240, y = 180, width, - height - }) { + height, + }: WatcherOptions) { this.initializeDOM(); this.value = value; @@ -36,7 +85,7 @@ export default class Watcher { this.height = height; } - initializeDOM() { + private initializeDOM(): void { const node = document.createElement("div"); node.classList.add("leopard__watcher"); @@ -52,8 +101,8 @@ export default class Watcher { slider.type = "range"; slider.classList.add("leopard__watcherSlider"); - slider.addEventListener("input", event => { - this.setValue(Number(event.target.value)); + slider.addEventListener("input", () => { + this.setValue(Number(slider.value)); }); node.append(slider); @@ -61,7 +110,7 @@ export default class Watcher { this._dom = { node, label, value, slider }; } - updateDOM(renderTarget) { + public updateDOM(renderTarget: HTMLElement | null): void { if (renderTarget && !renderTarget.contains(this._dom.node)) { renderTarget.append(this._dom.node); } @@ -86,11 +135,11 @@ export default class Watcher { const indexElem = document.createElement("div"); indexElem.classList.add("leopard__watcherListItemIndex"); - indexElem.innerText = index; + indexElem.innerText = String(index); const contentElem = document.createElement("div"); contentElem.classList.add("leopard__watcherListItemContent"); - contentElem.innerText = item.toString(); + contentElem.innerText = String(item); itemElem.append(indexElem); itemElem.append(contentElem); @@ -100,7 +149,7 @@ export default class Watcher { } else { // Render like a normal variable if (value !== this._previousValue) { - this._dom.value.innerText = value.toString(); + this._dom.value.innerText = String(value); } } @@ -112,7 +161,8 @@ export default class Watcher { // Set slider value if (this._style === "slider") { - this._dom.slider.value = value; + // TODO: handle non-numeric slider values + this._dom.slider.value = String(value); } // Update color @@ -126,58 +176,58 @@ export default class Watcher { this._dom.value.style.setProperty("--watcher-text-color", textColor); } - get visible() { + public get visible(): boolean { return this._visible; } - set visible(visible) { + public set visible(visible) { this._visible = visible; this._dom.node.style.visibility = visible ? "visible" : "hidden"; } - get x() { + public get x(): number { return this._x; } - set x(x) { + public set x(x) { this._x = x; this._dom.node.style.left = `${x - 240}px`; } - get y() { + public get y(): number { return this._y; } - set y(y) { + public set y(y) { this._y = y; this._dom.node.style.top = `${180 - y}px`; } - get width() { + public get width(): number | undefined { return this._width; } - set width(width) { + public set width(width) { this._width = width; if (width) { this._dom.node.style.width = `${width}px`; } else { - this._dom.node.style.width = undefined; + this._dom.node.style.removeProperty("width"); } } - get height() { + public get height(): number | undefined { return this._height; } - set height(height) { + public set height(height) { this._height = height; if (height) { this._dom.node.style.height = `${height}px`; } else { - this._dom.node.style.height = undefined; + this._dom.node.style.removeProperty("height"); } } - get style() { + public get style(): WatcherStyle { return this._style; } - set style(style) { + public set style(style) { this._style = style; this._dom.node.classList.toggle( "leopard__watcher--normal", @@ -193,34 +243,34 @@ export default class Watcher { ); } - get min() { + public get min(): number { return this._min; } - set min(min) { + public set min(min: number) { this._min = min; - this._dom.slider.min = min; + this._dom.slider.min = String(min); } - get max() { + public get max(): number { return this._max; } - set max(max) { + public set max(max: number) { this._max = max; - this._dom.slider.max = max; + this._dom.slider.max = String(max); } - get step() { + public get step(): number { return this._step; } - set step(step) { + public set step(step) { this._step = step; - this._dom.slider.step = step; + this._dom.slider.step = String(step); } - get label() { + public get label(): string { return this._label; } - set label(label) { + public set label(label) { this._label = label; this._dom.label.innerText = label; } diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 09c7210..0000000 --- a/src/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import Project from "./Project.js"; -import { Sprite, Stage } from "./Sprite.js"; -import Trigger from "./Trigger.js"; -import Watcher from "./Watcher.js"; -import Costume from "./Costume.js"; -import Color from "./Color.js"; -import Sound from "./Sound.js"; - -export { Project, Sprite, Stage, Trigger, Watcher, Costume, Color, Sound }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..db118dc --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +import Project from "./Project"; +import { Sprite, Stage } from "./Sprite"; +import Trigger from "./Trigger"; +import Watcher from "./Watcher"; +import Costume from "./Costume"; +import Color from "./Color"; +import Sound from "./Sound"; + +export { Project, Sprite, Stage, Trigger, Watcher, Costume, Color, Sound }; diff --git a/src/lib/decode-adpcm-audio.js b/src/lib/decode-adpcm-audio.ts similarity index 87% rename from src/lib/decode-adpcm-audio.js rename to src/lib/decode-adpcm-audio.ts index c971d1a..a7fc8f0 100644 --- a/src/lib/decode-adpcm-audio.js +++ b/src/lib/decode-adpcm-audio.ts @@ -32,14 +32,17 @@ const ADPCM_STEPS = [ const ADPCM_INDEX = [-1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8]; -export default function decodeADPCMAudio(ab, audioContext) { +export default function decodeADPCMAudio( + ab: ArrayBuffer, + audioContext: AudioContext +): Promise { const dv = new DataView(ab); // WAV magic number if (dv.getUint32(0) !== 0x52494646 || dv.getUint32(8) !== 0x57415645) { return Promise.reject(new Error("Unrecognized audio format")); } - const blocks = {}; + const blocks: Partial> = {}; const l = dv.byteLength - 8; let i = 12; while (i < l) { @@ -54,6 +57,12 @@ export default function decodeADPCMAudio(ab, audioContext) { i += 8 + dv.getUint32(i + 4, true); } + const factBlock = blocks.fact; + const dataBlock = blocks.data; + if (typeof factBlock !== "number" || typeof dataBlock !== "number") { + return Promise.reject(new Error("Invalid WAV")); + } + const format = dv.getUint16(20, true); const sampleRate = dv.getUint32(24, true); @@ -61,17 +70,17 @@ export default function decodeADPCMAudio(ab, audioContext) { const samplesPerBlock = dv.getUint16(38, true); const blockSize = (samplesPerBlock - 1) / 2 + 4; - const frameCount = dv.getUint32(blocks.fact + 8, true); + const frameCount = dv.getUint32(factBlock + 8, true); const buffer = audioContext.createBuffer(1, frameCount, sampleRate); const channel = buffer.getChannelData(0); - let sample; + let sample = 0; let index = 0; let step, code, delta; let lastByte = -1; - const offset = blocks.data + 8; + const offset = dataBlock + 8; let i = offset; let j = 0; // eslint-disable-next-line @@ -115,14 +124,14 @@ export default function decodeADPCMAudio(ab, audioContext) { return Promise.reject(new Error(`Unrecognized WAV format ${format}`)); } -export function isWavData(arrayBuffer) { +export function isWavData(arrayBuffer: ArrayBuffer): boolean { const dataView = new DataView(arrayBuffer); return ( dataView.getUint32(0) === 0x52494646 && dataView.getUint32(8) === 0x57415645 ); } -export function isADPCMData(arrayBuffer) { +export function isADPCMData(arrayBuffer: ArrayBuffer): boolean { const dataView = new DataView(arrayBuffer); const format = dataView.getUint16(20, true); return isWavData(arrayBuffer) && format === 17; diff --git a/src/lib/yielding.ts b/src/lib/yielding.ts new file mode 100644 index 0000000..317eb8b --- /dev/null +++ b/src/lib/yielding.ts @@ -0,0 +1,7 @@ +/** + * Utility type for a generator function that yields nothing until eventually + * resolving to a value. Used extensively in Leopard and defined here so we + * don't have to type out the full definition each time (and also so I don't + * have to go back and change it everywhere if this type turns out to be wrong). + */ +export type Yielding = Generator; diff --git a/src/renderer/BitmapSkin.js b/src/renderer/BitmapSkin.ts similarity index 74% rename from src/renderer/BitmapSkin.js rename to src/renderer/BitmapSkin.ts index 399508e..de6be8f 100644 --- a/src/renderer/BitmapSkin.js +++ b/src/renderer/BitmapSkin.ts @@ -1,7 +1,12 @@ -import Skin from "./Skin.js"; +import type Renderer from "../Renderer"; +import Skin from "./Skin"; export default class BitmapSkin extends Skin { - constructor(renderer, image) { + private _image: HTMLImageElement; + private _imageData: ImageData | null; + private _texture: WebGLTexture | null; + + public constructor(renderer: Renderer, image: HTMLImageElement) { super(renderer); this._image = image; @@ -11,7 +16,7 @@ export default class BitmapSkin extends Skin { this._setSizeFromImage(image); } - getImageData() { + public getImageData(): ImageData | null { // Make sure to handle potentially non-loaded textures if (!this._image.complete) return null; @@ -20,6 +25,7 @@ export default class BitmapSkin extends Skin { canvas.width = this._image.naturalWidth || this._image.width; canvas.height = this._image.naturalHeight || this._image.height; const ctx = canvas.getContext("2d"); + if (!ctx) return null; ctx.drawImage(this._image, 0, 0); // Cache image data so we can reuse it this._imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); @@ -28,7 +34,7 @@ export default class BitmapSkin extends Skin { return this._imageData; } - getTexture() { + public getTexture(): WebGLTexture | null { // Make sure to handle potentially non-loaded textures const image = this._image; if (!image.complete) return null; @@ -40,7 +46,7 @@ export default class BitmapSkin extends Skin { return this._texture; } - destroy() { + public destroy(): void { if (this._texture !== null) this.gl.deleteTexture(this._texture); } } diff --git a/src/renderer/Drawable.js b/src/renderer/Drawable.ts similarity index 76% rename from src/renderer/Drawable.js rename to src/renderer/Drawable.ts index d2a7819..de28a10 100644 --- a/src/renderer/Drawable.js +++ b/src/renderer/Drawable.ts @@ -1,15 +1,21 @@ -import Matrix from "./Matrix.js"; +import Matrix, { MatrixType } from "./Matrix"; -import Rectangle from "./Rectangle.js"; -import effectTransformPoint from "./effectTransformPoint.js"; -import { effectBitmasks } from "./effectInfo.js"; +import Rectangle from "./Rectangle"; +import effectTransformPoint from "./effectTransformPoint"; +import { effectBitmasks } from "./effectInfo"; +import type Skin from "./Skin"; -import { Sprite, Stage } from "../Sprite.js"; +import type Renderer from "../Renderer"; +import { Sprite, Stage } from "../Sprite"; // Returns the determinant of two vectors, the vector from A to B and the vector // from A to C. If positive, it means AC is counterclockwise from AB. // If negative, AC is clockwise from AB. -const determinant = (a, b, c) => { +const determinant = ( + a: [number, number], + b: [number, number], + c: [number, number] +): number => { return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); }; @@ -18,30 +24,44 @@ const determinant = (a, b, c) => { // TODO: store renderer-specific data on the sprite and have *it* set a // "transform changed" flag. class SpriteTransformDiff { - constructor(sprite) { + private _sprite: Sprite | Stage; + private _unset: boolean; + + private _lastX: Sprite["x"] | undefined; + private _lastY: Sprite["y"] | undefined; + private _lastRotation: Sprite["direction"] | undefined; + private _lastRotationStyle: Sprite["rotationStyle"] | undefined; + private _lastSize: Sprite["size"] | undefined; + private _lastCostume!: Sprite["costume"]; + private _lastCostumeLoaded!: boolean; + + public constructor(sprite: Sprite | Stage) { this._sprite = sprite; this._unset = true; this.update(); } - update() { - this._lastX = this._sprite.x; - this._lastY = this._sprite.y; - this._lastRotation = this._sprite.direction; - this._lastRotationStyle = this._sprite.rotationStyle; - this._lastSize = this._sprite.size; + public update(): void { + if (this._sprite instanceof Sprite) { + this._lastX = this._sprite.x; + this._lastY = this._sprite.y; + this._lastRotation = this._sprite.direction; + this._lastRotationStyle = this._sprite.rotationStyle; + this._lastSize = this._sprite.size; + } this._lastCostume = this._sprite.costume; this._lastCostumeLoaded = this._sprite.costume.img.complete; this._unset = false; } - get changed() { + public get changed(): boolean { return ( - this._lastX !== this._sprite.x || - this._lastY !== this._sprite.y || - this._lastRotation !== this._sprite.direction || - this._lastRotationStyle !== this._sprite.rotationStyle || - this._lastSize !== this._sprite.size || + (this._sprite instanceof Sprite && + (this._lastX !== this._sprite.x || + this._lastY !== this._sprite.y || + this._lastRotation !== this._sprite.direction || + this._lastRotationStyle !== this._sprite.rotationStyle || + this._lastSize !== this._sprite.size)) || this._lastCostume !== this._sprite.costume || this._lastCostumeLoaded !== this._sprite.costume.img.complete || this._unset @@ -51,7 +71,24 @@ class SpriteTransformDiff { // Renderer-specific data for an instance (the original or a clone) of a Sprite export default class Drawable { - constructor(renderer, sprite) { + private _renderer: Renderer; + // TODO: make this private + public _sprite: Sprite | Stage; + private _matrix: MatrixType; + private _matrixDiff: SpriteTransformDiff; + + private _convexHullImageData: ImageData | null; + private _convexHullMosaic: number; + private _convexHullPixelate: number; + private _convexHullWhirl: number; + private _convexHullFisheye: number; + private _convexHullPoints: [number, number][] | null; + + private _aabb: Rectangle; + private _tightBoundingBox: Rectangle; + private _convexHullMatrixDiff: SpriteTransformDiff; + + public constructor(renderer: Renderer, sprite: Sprite | Stage) { this._renderer = renderer; this._sprite = sprite; @@ -79,30 +116,38 @@ export default class Drawable { this._convexHullMatrixDiff = new SpriteTransformDiff(sprite); } - getCurrentSkin() { + public getCurrentSkin(): Skin { return this._renderer._getSkin(this._sprite.costume); } // Get the rough axis-aligned bounding box for this sprite. Not as tight as // getTightBoundingBox, especially when rotated. - getAABB() { + public getAABB(): Rectangle { return Rectangle.fromMatrix(this.getMatrix(), this._aabb); } // Get the Scratch-space tight bounding box for this sprite. - getTightBoundingBox() { + public getTightBoundingBox(): Rectangle { if (!this._convexHullMatrixDiff.changed) return this._tightBoundingBox; const matrix = this.getMatrix(); const convexHullPoints = this._calculateConvexHull(); // Maybe the costume isn't loaded yet. Return a 0x0 bounding box around the // center of the sprite. - if (convexHullPoints === null) { + if (convexHullPoints === null || this._convexHullImageData === null) { + if (this._sprite instanceof Stage) { + return Rectangle.fromBounds( + this._sprite.width / -2, + this._sprite.width / 2, + this._sprite.height / -2, + this._sprite.height / 2 + ); + } return Rectangle.fromBounds( this._sprite.x, - this._sprite.y, this._sprite.x, this._sprite.y, + this._sprite.y, this._tightBoundingBox ); } @@ -111,7 +156,7 @@ export default class Drawable { let right = -Infinity; let top = -Infinity; let bottom = Infinity; - const transformedPoint = [0, 0]; + const transformedPoint: [number, number] = [0, 0]; // Each convex hull point is the center of a pixel. However, said pixels // each have area. We must take into account the size of the pixels when @@ -146,7 +191,7 @@ export default class Drawable { return this._tightBoundingBox; } - _calculateConvexHull() { + private _calculateConvexHull(): [number, number][] | null { const sprite = this._sprite; const skin = this.getCurrentSkin(); const imageData = skin.getImageData( @@ -175,14 +220,14 @@ export default class Drawable { effectBitmasks.whirl | effectBitmasks.fisheye); - const leftHull = []; - const rightHull = []; + const leftHull: [number, number][] = []; + const rightHull: [number, number][] = []; const { width, height, data } = imageData; - const pixelPos = [0, 0]; - const effectPos = [0, 0]; - let currentPoint; + const pixelPos: [number, number] = [0, 0]; + const effectPos: [number, number] = [0, 0]; + let currentPoint: [number, number] | undefined; // Not Scratch-space: y increases as we go downwards // Loop over all rows of pixels in the costume, starting at the top for (let y = 0; y < height; y++) { @@ -208,7 +253,7 @@ export default class Drawable { } // There are no opaque pixels on this row. Go to the next one. - if (x >= width) continue; + if (x >= width || !currentPoint) continue; // If appending the current point to the left hull makes a // counterclockwise turn, we want to append the current point to it. @@ -283,7 +328,7 @@ export default class Drawable { return this._convexHullPoints; } - _calculateSpriteMatrix() { + private _calculateSpriteMatrix(): void { const m = this._matrix; Matrix.identity(m); const spr = this._sprite; @@ -325,7 +370,7 @@ export default class Drawable { this._matrixDiff.update(); } - getMatrix() { + public getMatrix(): MatrixType { // If all the values we used to calculate the matrix haven't changed since // we last calculated the matrix, we can just return the matrix as-is. if (this._matrixDiff.changed) { diff --git a/src/renderer/Matrix.js b/src/renderer/Matrix.ts similarity index 77% rename from src/renderer/Matrix.js rename to src/renderer/Matrix.ts index 44fae75..309f851 100644 --- a/src/renderer/Matrix.js +++ b/src/renderer/Matrix.ts @@ -5,14 +5,14 @@ // 3x3 transform matrix operations, unrolled 4 da speedz. export default class Matrix { // Create a new 3x3 transform matrix, initialized to the identity matrix. - static create() { + public static create(): MatrixType { const matrix = new Float32Array(9); Matrix.identity(matrix); return matrix; } // Reset a matrix to the identity matrix - static identity(dst) { + public static identity(dst: MatrixType): MatrixType { dst[0] = 1; dst[1] = 0; dst[2] = 0; @@ -26,7 +26,12 @@ export default class Matrix { } // Translate a matrix by the given X and Y values - static translate(dst, src, x, y) { + public static translate( + dst: MatrixType, + src: MatrixType, + x: number, + y: number + ): MatrixType { const a00 = src[0], a01 = src[1], a02 = src[2], @@ -52,7 +57,11 @@ export default class Matrix { } // Rotate a matrix, in radians - static rotate(dst, src, rad) { + public static rotate( + dst: MatrixType, + src: MatrixType, + rad: number + ): MatrixType { const a00 = src[0], a01 = src[1], a02 = src[2], @@ -80,7 +89,12 @@ export default class Matrix { } // Scale a matrix by the given X and Y values - static scale(dst, src, x, y) { + public static scale( + dst: MatrixType, + src: MatrixType, + x: number, + y: number + ): MatrixType { dst[0] = x * src[0]; dst[1] = x * src[1]; dst[2] = x * src[2]; @@ -96,7 +110,11 @@ export default class Matrix { } // Transform a 2D point by the given matrix - static transformPoint(m, dst, src) { + public static transformPoint( + m: MatrixType, + dst: [number, number], + src: [number, number] + ): [number, number] { const x = src[0]; const y = src[1]; dst[0] = m[0] * x + m[3] * y + m[6]; @@ -104,3 +122,5 @@ export default class Matrix { return dst; } } + +export type MatrixType = Float32Array; diff --git a/src/renderer/PenSkin.js b/src/renderer/PenSkin.ts similarity index 80% rename from src/renderer/PenSkin.js rename to src/renderer/PenSkin.ts index 542c817..2a4b6df 100644 --- a/src/renderer/PenSkin.js +++ b/src/renderer/PenSkin.ts @@ -1,8 +1,18 @@ -import Skin from "./Skin.js"; -import ShaderManager from "./ShaderManager.js"; +import Skin from "./Skin"; +import ShaderManager from "./ShaderManager"; +import type Color from "../Color"; +import type { RGBANormalized } from "../Color"; +import type Renderer from "../Renderer"; +import type { FramebufferInfo } from "../Renderer"; export default class PenSkin extends Skin { - constructor(renderer, width, height) { + public _framebufferInfo: FramebufferInfo; + private _lastPenState: { + size: number; + color: RGBANormalized; + }; + + public constructor(renderer: Renderer, width: number, height: number) { super(renderer); this.width = width; this.height = height; @@ -16,23 +26,32 @@ export default class PenSkin extends Skin { this._lastPenState = { size: 0, - color: [0, 0, 0, 0] + color: [0, 0, 0, 0], }; this.clear(); } - destroy() { + public destroy(): void { const gl = this.gl; gl.deleteTexture(this._framebufferInfo.texture); gl.deleteFramebuffer(this._framebufferInfo.framebuffer); } - getTexture() { + public getTexture(): WebGLTexture { return this._framebufferInfo.texture; } - penLine(pt1, pt2, color, size) { + public getImageData(): ImageData | null { + return null; + } + + public penLine( + pt1: { x: number; y: number }, + pt2: { x: number; y: number }, + color: Color, + size: number + ): void { const renderer = this.renderer; renderer._setFramebuffer(this._framebufferInfo); @@ -102,7 +121,7 @@ export default class PenSkin extends Skin { gl.drawArrays(gl.TRIANGLES, 0, 6); } - clear() { + public clear(): void { this.renderer._setFramebuffer(this._framebufferInfo); const gl = this.gl; gl.clearColor(0, 0, 0, 0); diff --git a/src/renderer/Rectangle.js b/src/renderer/Rectangle.ts similarity index 73% rename from src/renderer/Rectangle.js rename to src/renderer/Rectangle.ts index 149bfa4..66a668e 100644 --- a/src/renderer/Rectangle.js +++ b/src/renderer/Rectangle.ts @@ -1,5 +1,12 @@ +import type { MatrixType } from "./Matrix"; + export default class Rectangle { - constructor() { + public left: number; + public right: number; + public bottom: number; + public top: number; + + public constructor() { this.left = -Infinity; this.right = Infinity; this.bottom = -Infinity; @@ -8,8 +15,13 @@ export default class Rectangle { return this; } - static fromBounds(left, right, bottom, top, result) { - if (!result) result = new Rectangle(); + public static fromBounds( + left: number, + right: number, + bottom: number, + top: number, + result = new Rectangle() + ): Rectangle { result.left = left; result.right = right; result.bottom = bottom; @@ -19,9 +31,10 @@ export default class Rectangle { } // Initialize a bounding box around a sprite given the sprite's transform matrix. - static fromMatrix(matrix, result) { - if (!result) result = new Rectangle(); - + public static fromMatrix( + matrix: MatrixType, + result = new Rectangle() + ): Rectangle { // Adapted somewhat from https://github.com/LLK/scratch-render/blob/develop/docs/Rectangle-AABB-Matrix.md const xa = matrix[0] / 2; const xb = matrix[3] / 2; @@ -42,7 +55,7 @@ export default class Rectangle { } // Initialize from another rectangle. - static copy(src, dst) { + public static copy(src: Rectangle, dst: Rectangle): Rectangle { dst.left = src.left; dst.right = src.right; dst.bottom = src.bottom; @@ -52,7 +65,7 @@ export default class Rectangle { // Push this rectangle out to integer bounds. // This takes a conservative approach and will always expand the rectangle outwards. - snapToInt() { + public snapToInt(): this { this.left = Math.floor(this.left); this.right = Math.ceil(this.right); this.bottom = Math.floor(this.bottom); @@ -62,7 +75,7 @@ export default class Rectangle { } // Check whether any part of this rectangle touches another rectangle. - intersects(rect) { + public intersects(rect: Rectangle): boolean { return ( this.left <= rect.right && rect.left <= this.right && @@ -72,14 +85,14 @@ export default class Rectangle { } // Check whether a given point is inside this rectangle. - containsPoint(x, y) { + public containsPoint(x: number, y: number): boolean { return ( x >= this.left && x <= this.right && y >= this.bottom && y <= this.top ); } // Clamp this rectangle within bounds. - clamp(left, right, bottom, top) { + public clamp(left: number, right: number, bottom: number, top: number): this { this.left = Math.min(Math.max(this.left, left), right); this.right = Math.max(Math.min(this.right, right), left); this.bottom = Math.min(Math.max(this.bottom, bottom), top); @@ -89,7 +102,11 @@ export default class Rectangle { } // Compute the union of two rectangles. - static union(rect1, rect2, result = new Rectangle()) { + public static union( + rect1: Rectangle, + rect2: Rectangle, + result = new Rectangle() + ): Rectangle { result.left = Math.min(rect1.left, rect2.left); result.right = Math.max(rect1.right, rect2.right); result.bottom = Math.min(rect1.bottom, rect2.bottom); @@ -99,7 +116,11 @@ export default class Rectangle { } // Compute the intersection of two rectangles. - static intersection(rect1, rect2, result = new Rectangle()) { + public static intersection( + rect1: Rectangle, + rect2: Rectangle, + result = new Rectangle() + ): Rectangle { result.left = Math.max(rect1.left, rect2.left); result.right = Math.min(rect1.right, rect2.right); result.bottom = Math.max(rect1.bottom, rect2.bottom); @@ -108,11 +129,11 @@ export default class Rectangle { return result; } - get width() { + public get width(): number { return this.right - this.left; } - get height() { + public get height(): number { return this.top - this.bottom; } } diff --git a/src/renderer/ShaderManager.js b/src/renderer/ShaderManager.js deleted file mode 100644 index 4118d2d..0000000 --- a/src/renderer/ShaderManager.js +++ /dev/null @@ -1,135 +0,0 @@ -import { SpriteShader, PenLineShader } from "./Shaders.js"; -import { effectNames, effectBitmasks } from "./effectInfo.js"; - -// Everything contained in a shader. It contains both the program, and the locations of the shader inputs. -class Shader { - constructor(gl, program) { - this.gl = gl; - this.program = program; - this.uniforms = {}; - this.attribs = {}; - - // In order to pass a value into a shader as an attribute or uniform, you need to know its location. - // This maps the names of attributes and uniforms to their locations, accessible via the `uniforms` and `attribs` - // properties. - const numActiveUniforms = gl.getProgramParameter( - program, - gl.ACTIVE_UNIFORMS - ); - for (let i = 0; i < numActiveUniforms; i++) { - const { name } = gl.getActiveUniform(program, i); - this.uniforms[name] = gl.getUniformLocation(program, name); - } - - const numActiveAttributes = gl.getProgramParameter( - program, - gl.ACTIVE_ATTRIBUTES - ); - for (let i = 0; i < numActiveAttributes; i++) { - const { name } = gl.getActiveAttrib(program, i); - this.attribs[name] = gl.getAttribLocation(program, name); - } - } -} - -class ShaderManager { - constructor(renderer) { - this.renderer = renderer; - this.gl = renderer.gl; - - // We compile shaders on-demand. Create one shader cache per draw mode. - this._shaderCache = {}; - for (const drawMode of Object.keys(ShaderManager.DrawModes)) { - this._shaderCache[drawMode] = new Map(); - } - } - - // Creates and compiles a vertex or fragment shader from the given source code. - _createShader(source, type) { - const gl = this.gl; - const shader = gl.createShader(type); - gl.shaderSource(shader, source); - gl.compileShader(shader); - - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - const info = gl.getShaderInfoLog(shader); - throw "Could not compile WebGL program. \n" + info; - } - - return shader; - } - - getShader(drawMode, effectBitmask = 0) { - const gl = this.gl; - // Each combination of enabled effects is compiled to a different shader, with only the needed effect code. - // Check if we've already compiled the shader with this set of enabled effects. - const shaderMap = this._shaderCache[drawMode]; - if (shaderMap.has(effectBitmask)) { - return shaderMap.get(effectBitmask); - } else { - let shaderCode; - switch (drawMode) { - case ShaderManager.DrawModes.PEN_LINE: { - shaderCode = PenLineShader; - break; - } - default: { - shaderCode = SpriteShader; - break; - } - } - - // Use #define statements for conditional compilation in shader code. - let define = `#define DRAW_MODE_${drawMode}\n`; - - // Add #defines for each enabled effect. - for (let i = 0; i < effectNames.length; i++) { - const effectName = effectNames[i]; - if ((effectBitmask & effectBitmasks[effectName]) !== 0) { - define += `#define EFFECT_${effectName}\n`; - } - } - - const vertShader = this._createShader( - define + shaderCode.vertex, - gl.VERTEX_SHADER - ); - const fragShader = this._createShader( - define + shaderCode.fragment, - gl.FRAGMENT_SHADER - ); - - // Combine the vertex and fragment shaders into a single GL program. - const program = gl.createProgram(); - gl.attachShader(program, vertShader); - gl.attachShader(program, fragShader); - gl.linkProgram(program); - - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - const info = gl.getProgramInfoLog(program); - throw new Error("Could not compile WebGL program. \n" + info); - } - - const shader = new Shader(gl, program); - shaderMap.set(effectBitmask, shader); - return shader; - } - } -} - -ShaderManager.DrawModes = { - // Used for drawing sprites normally - DEFAULT: "DEFAULT", - // Used for "touching" tests. Discards transparent pixels. - SILHOUETTE: "SILHOUETTE", - // Used for "color is touching color" tests. Only renders sprite colors which are close to the color passed in, and - // discards all pixels of a different color. - COLOR_MASK: "COLOR_MASK", - // Used for picking the topmost sprite and identifying which one it is. - // Assigns a color to each sprite. - SPRITE_ID: "SPRITE_ID", - // Used for drawing pen lines. - PEN_LINE: "PEN_LINE" -}; - -export default ShaderManager; diff --git a/src/renderer/ShaderManager.ts b/src/renderer/ShaderManager.ts new file mode 100644 index 0000000..8ed6849 --- /dev/null +++ b/src/renderer/ShaderManager.ts @@ -0,0 +1,157 @@ +import { SpriteShader, PenLineShader } from "./Shaders"; +import { effectNames, effectBitmasks } from "./effectInfo"; +import type Renderer from "../Renderer"; + +// Everything contained in a shader. It contains both the program, and the locations of the shader inputs. +class Shader { + private gl: WebGLRenderingContext; + public program: WebGLProgram; + // TODO: strongly type these + public uniforms: Record; + public attribs: Record; + + public constructor(gl: WebGLRenderingContext, program: WebGLProgram) { + this.gl = gl; + this.program = program; + this.uniforms = {}; + this.attribs = {}; + + // In order to pass a value into a shader as an attribute or uniform, you need to know its location. + // This maps the names of attributes and uniforms to their locations, accessible via the `uniforms` and `attribs` + // properties. + const numActiveUniforms = gl.getProgramParameter( + program, + gl.ACTIVE_UNIFORMS + ) as number; + for (let i = 0; i < numActiveUniforms; i++) { + const { name } = gl.getActiveUniform(program, i)!; + this.uniforms[name] = gl.getUniformLocation(program, name)!; + } + + const numActiveAttributes = gl.getProgramParameter( + program, + gl.ACTIVE_ATTRIBUTES + ) as number; + for (let i = 0; i < numActiveAttributes; i++) { + const { name } = gl.getActiveAttrib(program, i)!; + this.attribs[name] = gl.getAttribLocation(program, name)!; + } + } +} + +type DrawMode = keyof typeof ShaderManager["DrawModes"]; + +class ShaderManager { + private renderer: Renderer; + private gl: WebGLRenderingContext; + + private _shaderCache: Record>; + + public constructor(renderer: Renderer) { + this.renderer = renderer; + this.gl = renderer.gl; + + // We compile shaders on-demand. Create one shader cache per draw mode. + this._shaderCache = {} as Record>; + for (const drawMode of Object.keys(ShaderManager.DrawModes)) { + this._shaderCache[drawMode as DrawMode] = new Map(); + } + } + + // Creates and compiles a vertex or fragment shader from the given source code. + private _createShader( + source: string, + type: + | WebGLRenderingContext["FRAGMENT_SHADER"] + | WebGLRenderingContext["VERTEX_SHADER"] + ): WebGLShader { + const gl = this.gl; + const shader = gl.createShader(type); + if (!shader) throw new Error("Could not create shader."); + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(shader) ?? ""; + throw new Error("Could not compile WebGL program. \n" + info); + } + + return shader; + } + + public getShader(drawMode: DrawMode, effectBitmask = 0): Shader { + const gl = this.gl; + // Each combination of enabled effects is compiled to a different shader, with only the needed effect code. + // Check if we've already compiled the shader with this set of enabled effects. + const shaderMap = this._shaderCache[drawMode]; + const existingShader = shaderMap.get(effectBitmask); + if (existingShader) return existingShader; + + let shaderCode; + switch (drawMode) { + case ShaderManager.DrawModes.PEN_LINE: { + shaderCode = PenLineShader; + break; + } + default: { + shaderCode = SpriteShader; + break; + } + } + + // Use #define statements for conditional compilation in shader code. + let define = `#define DRAW_MODE_${drawMode}\n`; + + // Add #defines for each enabled effect. + for (let i = 0; i < effectNames.length; i++) { + const effectName = effectNames[i]; + if ((effectBitmask & effectBitmasks[effectName]) !== 0) { + define += `#define EFFECT_${effectName}\n`; + } + } + + const vertShader = this._createShader( + define + shaderCode.vertex, + gl.VERTEX_SHADER + ); + const fragShader = this._createShader( + define + shaderCode.fragment, + gl.FRAGMENT_SHADER + ); + + // Combine the vertex and fragment shaders into a single GL program. + const program = gl.createProgram(); + if (!program) throw new Error("Could not create program"); + gl.attachShader(program, vertShader); + gl.attachShader(program, fragShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const info = gl.getProgramInfoLog(program) ?? ""; + throw new Error("Could not compile WebGL program. \n" + info); + } + + const shader = new Shader(gl, program); + shaderMap.set(effectBitmask, shader); + return shader; + } + + public static DrawModes = { + // Used for drawing sprites normally + DEFAULT: "DEFAULT", + // Used for "touching" tests. Discards transparent pixels. + SILHOUETTE: "SILHOUETTE", + // Used for "color is touching color" tests. Only renders sprite colors which are close to the color passed in, and + // discards all pixels of a different color. + COLOR_MASK: "COLOR_MASK", + // Used for picking the topmost sprite and identifying which one it is. + // Assigns a color to each sprite. + SPRITE_ID: "SPRITE_ID", + // Used for drawing pen lines. + PEN_LINE: "PEN_LINE", + } as const; +} + +export default ShaderManager; +export { Shader }; +export type { DrawMode }; diff --git a/src/renderer/Shaders.js b/src/renderer/Shaders.ts similarity index 96% rename from src/renderer/Shaders.js rename to src/renderer/Shaders.ts index b443ec6..2dceae7 100644 --- a/src/renderer/Shaders.js +++ b/src/renderer/Shaders.ts @@ -1,6 +1,4 @@ -const SpriteShader = {}; - -SpriteShader.vertex = ` +const spriteShaderVertex = ` precision mediump float; attribute vec2 a_position; @@ -15,7 +13,7 @@ void main() { } `; -SpriteShader.fragment = ` +const spriteShaderFragment = ` precision mediump float; const float epsilon = 1e-3; @@ -184,10 +182,12 @@ void main() { gl_FragColor = color; } `; +const SpriteShader = { + vertex: spriteShaderVertex, + fragment: spriteShaderFragment, +}; -const PenLineShader = {}; - -PenLineShader.vertex = ` +const penLineShaderVertex = ` precision mediump float; attribute vec2 a_position; @@ -249,7 +249,7 @@ void main() { } `; -PenLineShader.fragment = ` +const penLineShaderFragment = ` precision mediump float; uniform sampler2D u_texture; @@ -280,4 +280,9 @@ void main() { } `; +const PenLineShader = { + vertex: penLineShaderVertex, + fragment: penLineShaderFragment, +}; + export { SpriteShader, PenLineShader }; diff --git a/src/renderer/Skin.js b/src/renderer/Skin.ts similarity index 55% rename from src/renderer/Skin.js rename to src/renderer/Skin.ts index dc709f6..0f29528 100644 --- a/src/renderer/Skin.js +++ b/src/renderer/Skin.ts @@ -1,25 +1,40 @@ -export default class Skin { - constructor(renderer) { +import type Renderer from "../Renderer"; + +export default abstract class Skin { + protected renderer: Renderer; + protected gl: WebGLRenderingContext; + public width: number; + public height: number; + + public constructor(renderer: Renderer) { this.renderer = renderer; this.gl = renderer.gl; + this.width = 0; + this.height = 0; } - // Get the skin's texture for a given (screen-space) scale. - /* eslint-disable-next-line no-unused-vars */ - getTexture(scale) { - return null; - } + /** + * Get the skin's texture at a given screen-space scale. + * @param scale The screen-space scale factor for the texture, as a ratio of screen pixels to texture pixels. + */ + public abstract getTexture(scale: number): WebGLTexture | null; - // Get the skin image's ImageData at a given (screen-space) scale. - // eslint-disable-next-line no-unused-vars - getImageData(scale) { - throw new Error("getImageData not implemented for this skin type"); - } + /** + * Gets the raster ImageData for a skin's texture at a given screen-space scale. + * @param scale The screen-space scale factor for the texture, as a ratio of screen pixels to texture pixels. + */ + public abstract getImageData(scale: number): ImageData | null; // Helper function to create a texture from an image and handle all the boilerplate. - _makeTexture(image, filtering) { + protected _makeTexture( + image: HTMLImageElement | HTMLCanvasElement | null, + filtering: + | WebGLRenderingContext["NEAREST"] + | WebGLRenderingContext["LINEAR"] + ): WebGLTexture { const gl = this.gl; const glTexture = gl.createTexture(); + if (!glTexture) throw new Error("Could not create texture"); gl.bindTexture(gl.TEXTURE_2D, glTexture); // These need to be set because most sprite textures don't have power-of-two dimensions. // Non-power-of-two textures only work with gl.CLAMP_TO_EDGE wrapping behavior, @@ -42,7 +57,7 @@ export default class Skin { } // Helper function to set this skin's size based on an image that may or may not be loaded. - _setSizeFromImage(image) { + protected _setSizeFromImage(image: HTMLImageElement): void { if (image.complete) { this.width = image.naturalWidth; this.height = image.naturalHeight; @@ -55,5 +70,5 @@ export default class Skin { } // Clean up any textures or other objets created by this skin. - destroy() {} + public abstract destroy(): void; } diff --git a/src/renderer/SpeechBubbleSkin.js b/src/renderer/SpeechBubbleSkin.ts similarity index 71% rename from src/renderer/SpeechBubbleSkin.js rename to src/renderer/SpeechBubbleSkin.ts index c5878cf..bb2f9ae 100644 --- a/src/renderer/SpeechBubbleSkin.js +++ b/src/renderer/SpeechBubbleSkin.ts @@ -1,19 +1,34 @@ -import Skin from "./Skin.js"; +import Skin from "./Skin"; +import type Renderer from "../Renderer"; +import type { SpeechBubble, SpeechBubbleStyle } from "../Sprite"; const bubbleStyle = { maxLineWidth: 170, minWidth: 50, strokeWidth: 4, padding: 12, - tailHeight: 12 -}; + tailHeight: 12, +} as const; // TODO: multiline speech bubbles export default class SpeechBubbleSkin extends Skin { - constructor(renderer, bubble) { + private _canvas: HTMLCanvasElement; + private _ctx: CanvasRenderingContext2D; + private _texture: WebGLTexture; + private _bubble: SpeechBubble; + private _flipped: boolean; + private _rendered: boolean; + private _renderedScale: number; + public offsetX: number; + public offsetY: number; + + public constructor(renderer: Renderer, bubble: SpeechBubble) { super(renderer); this._canvas = document.createElement("canvas"); + const ctx = this._canvas.getContext("2d"); + if (ctx === null) throw new Error("Could not get canvas context"); + this._ctx = ctx; this._texture = this._makeTexture(null, this.gl.LINEAR); this._bubble = bubble; this._flipped = false; @@ -24,27 +39,36 @@ export default class SpeechBubbleSkin extends Skin { this.height = 0; this.offsetX = -bubbleStyle.strokeWidth / 2; this.offsetY = this.offsetX + bubbleStyle.tailHeight; - - this._renderBubble(this._bubble); } // To ensure proper text measurement and drawing, it's necessary to restyle the canvas after resizing it. - _restyleCanvas() { - const ctx = this._canvas.getContext("2d"); + private _restyleCanvas(): void { + const ctx = this._ctx; ctx.font = "16px sans-serif"; ctx.textBaseline = "hanging"; } - set flipped(flipped) { + public get flipped(): boolean { + return this._flipped; + } + + public set flipped(flipped) { this._flipped = flipped; this._rendered = false; } - _renderBubble(bubble, scale) { + private _renderBubble(bubble: SpeechBubble, scale: number): void { const canvas = this._canvas; - const ctx = canvas.getContext("2d"); - - const renderBubbleBackground = (x, y, w, h, r, style) => { + const ctx = this._ctx; + + const renderBubbleBackground = ( + x: number, + y: number, + w: number, + h: number, + r: number, + style: SpeechBubbleStyle + ): void => { if (r > w / 2) r = w / 2; if (r > h / 2) r = h / 2; if (r < 0) return; @@ -57,7 +81,7 @@ export default class SpeechBubbleSkin extends Skin { ctx.lineTo(Math.min(x + 3 * r, x + w - r), y + h); ctx.lineTo(x + r / 2, y + h + r); ctx.lineTo(x + r, y + h); - } else if (style === "think") { + } else { ctx.ellipse(x + r * 2.25, y + h, (r * 3) / 4, r / 2, 0, 0, Math.PI); } ctx.arcTo(x, y + h, x, y, r); @@ -124,7 +148,7 @@ export default class SpeechBubbleSkin extends Skin { this._renderedScale = scale; } - getTexture(scale) { + public getTexture(scale: number): WebGLTexture { if (!this._rendered || this._renderedScale !== scale) { this._renderBubble(this._bubble, scale); const gl = this.gl; @@ -142,7 +166,17 @@ export default class SpeechBubbleSkin extends Skin { return this._texture; } - destroy() { + public getImageData(scale: number): ImageData | null { + this.getTexture(scale); + return this._ctx.getImageData( + 0, + 0, + this._canvas.width, + this._canvas.height + ); + } + + public destroy(): void { this.gl.deleteTexture(this._texture); } } diff --git a/src/renderer/VectorSkin.js b/src/renderer/VectorSkin.ts similarity index 69% rename from src/renderer/VectorSkin.js rename to src/renderer/VectorSkin.ts index 21210ef..065b403 100644 --- a/src/renderer/VectorSkin.js +++ b/src/renderer/VectorSkin.ts @@ -1,51 +1,62 @@ -import Skin from "./Skin.js"; +import Skin from "./Skin"; +import type Renderer from "../Renderer"; // This means that the smallest mipmap will be 1/(2**4)th the size of the sprite's "100%" size. const MIPMAP_OFFSET = 4; export default class VectorSkin extends Skin { - constructor(renderer, image) { + private _image: HTMLImageElement; + private _canvas: HTMLCanvasElement; + private _ctx: CanvasRenderingContext2D; + private _imageDataMipLevel: number; + private _imageData: ImageData | null; + private _maxTextureSize: number; + private _mipmaps: Map; + + public constructor(renderer: Renderer, image: HTMLImageElement) { super(renderer); this._image = image; this._canvas = document.createElement("canvas"); + const ctx = this._canvas.getContext("2d"); + if (!ctx) throw new Error("Could not get canvas context"); + this._ctx = ctx; this._imageDataMipLevel = 0; this._imageData = null; this._maxTextureSize = renderer.gl.getParameter( renderer.gl.MAX_TEXTURE_SIZE - ); + ) as number; this._setSizeFromImage(image); this._mipmaps = new Map(); } - static mipLevelForScale(scale) { + private static mipLevelForScale(scale: number): number { return Math.max(Math.ceil(Math.log2(scale)) + MIPMAP_OFFSET, 0); } - getImageData(scale) { + public getImageData(scale: number): ImageData | null { if (!this._image.complete) return null; // Round off the scale of the image data drawn to a given power-of-two mip level. const mipLevel = VectorSkin.mipLevelForScale(scale); if (!this._imageData || this._imageDataMipLevel !== mipLevel) { - const canvas = this._drawSvgToCanvas(mipLevel); - if (canvas === null) return null; + const ctx = this._drawSvgToCanvas(mipLevel); + if (ctx === null) return null; + const { canvas } = ctx; // Cache image data so we can reuse it - this._imageData = canvas - .getContext("2d") - .getImageData(0, 0, canvas.width, canvas.height); + this._imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); this._imageDataMipLevel = mipLevel; } return this._imageData; } - _drawSvgToCanvas(mipLevel) { + private _drawSvgToCanvas(mipLevel: number): CanvasRenderingContext2D | null { const scale = 2 ** (mipLevel - MIPMAP_OFFSET); const image = this._image; @@ -61,30 +72,30 @@ export default class VectorSkin extends Skin { } // Instead of uploading the image to WebGL as a texture, render the image to a canvas and upload the canvas. - const canvas = this._canvas; - const ctx = canvas.getContext("2d"); + const ctx = this._ctx; + const { canvas } = ctx; canvas.width = width; canvas.height = height; ctx.drawImage(image, 0, 0, width, height); - return this._canvas; + return ctx; } // TODO: handle proper subpixel positioning when SVG viewbox has non-integer coordinates // This will require rethinking costume + project loading probably - _createMipmap(mipLevel) { + private _createMipmap(mipLevel: number): void { // Instead of uploading the image to WebGL as a texture, render the image to a canvas and upload the canvas. - const canvas = this._drawSvgToCanvas(mipLevel); + const ctx = this._drawSvgToCanvas(mipLevel); this._mipmaps.set( mipLevel, // Use linear (i.e. smooth) texture filtering for vectors // If the image is 0x0, we return null. Check for that. - canvas === null ? null : this._makeTexture(canvas, this.gl.LINEAR) + ctx === null ? null : this._makeTexture(ctx.canvas, this.gl.LINEAR) ); } - getTexture(scale) { + public getTexture(scale: number): WebGLTexture | null { if (!this._image.complete) return null; // Because WebGL doesn't support vector graphics, substitute a bunch of bitmaps. @@ -97,10 +108,10 @@ export default class VectorSkin extends Skin { const mipLevel = VectorSkin.mipLevelForScale(scale); if (!this._mipmaps.has(mipLevel)) this._createMipmap(mipLevel); - return this._mipmaps.get(mipLevel); + return this._mipmaps.get(mipLevel) ?? null; } - destroy() { + public destroy(): void { for (const mip of this._mipmaps.values()) { this.gl.deleteTexture(mip); } diff --git a/src/renderer/effectInfo.js b/src/renderer/effectInfo.ts similarity index 61% rename from src/renderer/effectInfo.js rename to src/renderer/effectInfo.ts index fe14cb8..e8346b2 100644 --- a/src/renderer/effectInfo.js +++ b/src/renderer/effectInfo.ts @@ -6,12 +6,17 @@ const effectNames = [ "pixelate", "mosaic", "brightness", - "ghost" -]; + "ghost", +] as const; -const effectBitmasks = {}; -for (let i = 0; i < effectNames.length; i++) { - effectBitmasks[effectNames[i]] = 1 << i; -} +const effectBitmasks = { + color: 1, + fisheye: 2, + whirl: 4, + pixelate: 8, + mosaic: 16, + brightness: 32, + ghost: 64, +} as const; export { effectNames, effectBitmasks }; diff --git a/src/renderer/effectTransformPoint.js b/src/renderer/effectTransformPoint.ts similarity index 93% rename from src/renderer/effectTransformPoint.js rename to src/renderer/effectTransformPoint.ts index bb1590d..c3f058f 100644 --- a/src/renderer/effectTransformPoint.js +++ b/src/renderer/effectTransformPoint.ts @@ -1,10 +1,15 @@ -import { effectBitmasks } from "./effectInfo.js"; +import { effectBitmasks } from "./effectInfo"; +import type Drawable from "./Drawable"; const CENTER = 0.5; const EPSILON = 1e-3; // Transform a texture-space point using the effects defined on the given drawable. -const effectTransformPoint = (drawable, src, dst) => { +const effectTransformPoint = ( + drawable: Drawable, + src: [number, number], + dst: [number, number] +): [number, number] => { const { effects } = drawable._sprite; const effectBitmask = effects._bitmask; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d530d53 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2020", + + "esModuleInterop": true, + "moduleResolution": "node", + "sourceMap": true, + "strict": true, + "allowJs": true, + + "outDir": "dist", + "declaration": true, + "declarationDir": "dist" + }, + "include": [ + "src/**/*" + ], + "exclude": ["dist"] +}