commit a05d5f454743817bbe79cd9cd466c064519e0e0f Author: azykov@mail.ru Date: Fri Sep 4 16:03:17 2026 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/.vite/deps/_metadata.json b/.vite/deps/_metadata.json new file mode 100644 index 0000000..365c359 --- /dev/null +++ b/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "8e882497", + "configHash": "9f16650f", + "lockfileHash": "e3b0c442", + "browserHash": "1db893f2", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/.vite/deps/package.json b/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/index.html b/index.html new file mode 100644 index 0000000..8915e34 --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + + forest + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0764578 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1340 @@ +{ + "name": "forest", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "forest", + "version": "0.0.0", + "dependencies": { + "break_eternity.js": "^2.1.3", + "mobx": "^7.0.3", + "mobx-react-lite": "^5.0.3", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } + }, + "../engine": { + "name": "@incgame/engine", + "version": "1.0.0", + "extraneous": true, + "license": "ISC", + "dependencies": { + "break_eternity.js": "^2.1.3" + }, + "devDependencies": { + "typescript": "^5.6.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz", + "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz", + "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz", + "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz", + "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz", + "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz", + "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz", + "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz", + "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz", + "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz", + "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz", + "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz", + "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz", + "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz", + "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz", + "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz", + "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz", + "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz", + "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz", + "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/break_eternity.js": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/break_eternity.js/-/break_eternity.js-2.1.3.tgz", + "integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/mobx": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-7.0.3.tgz", + "integrity": "sha512-KopyyzqZ8xT1fqxFNl8ItKKMpGifRe2ySaNAHs80/cx0mL1bpJtJ46S+FSyzXKRjW0TODWj5zwq/Y9hUdJUy4w==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + } + }, + "node_modules/mobx-react-lite": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-5.0.3.tgz", + "integrity": "sha512-8qT/d/8Cg8ljt+heKhoFHNoqgedyeSGzdTixT0lgbFMMFlWfHUcXHZ8UD63eQi3ELjkxaH1VU07lXWhRwM0cVA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + }, + "peerDependencies": { + "mobx": "^7.0.0", + "react": "^18 || ^19" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", + "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.81.0", + "@oxlint/binding-android-arm64": "1.81.0", + "@oxlint/binding-darwin-arm64": "1.81.0", + "@oxlint/binding-darwin-x64": "1.81.0", + "@oxlint/binding-freebsd-x64": "1.81.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", + "@oxlint/binding-linux-arm-musleabihf": "1.81.0", + "@oxlint/binding-linux-arm64-gnu": "1.81.0", + "@oxlint/binding-linux-arm64-musl": "1.81.0", + "@oxlint/binding-linux-ppc64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-musl": "1.81.0", + "@oxlint/binding-linux-s390x-gnu": "1.81.0", + "@oxlint/binding-linux-x64-gnu": "1.81.0", + "@oxlint/binding-linux-x64-musl": "1.81.0", + "@oxlint/binding-openharmony-arm64": "1.81.0", + "@oxlint/binding-win32-arm64-msvc": "1.81.0", + "@oxlint/binding-win32-ia32-msvc": "1.81.0", + "@oxlint/binding-win32-x64-msvc": "1.81.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c19156d --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "forest", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "break_eternity.js": "^2.1.3", + "mobx": "^7.0.3", + "mobx-react-lite": "^5.0.3", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..7bc8431 --- /dev/null +++ b/src/App.css @@ -0,0 +1,217 @@ +.app { + display: flex; + flex-direction: column; + height: 100%; +} + +.topbar { + display: flex; + align-items: center; + gap: 20px; + padding: 10px 16px; + background: var(--panel-bg); + border-bottom: 4px solid var(--panel-border); + box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.2); + flex-shrink: 0; +} + +.topbar__title { + font-size: 14px; + margin: 0; + color: var(--accent); + text-shadow: 2px 2px 0 var(--panel-border); +} + +.topbar .spacer { + flex-grow: 1; +} + +.inventory-slot { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: rgba(0, 0, 0, 0.25); + border: 3px solid var(--panel-border); + font-size: 14px; +} + +.main-row { + display: flex; + flex-direction: row; + flex-grow: 1; + min-height: 0; +} + +.sidebar { + width: 260px; + flex-shrink: 0; + background: var(--panel-bg); + border-right: 4px solid var(--panel-border); + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.sidebar__title { + font-size: 13px; + margin: 0; + color: var(--accent); + text-shadow: 2px 2px 0 var(--panel-border); +} + +.upgrade-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.upgrade-btn { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + background: rgba(0, 0, 0, 0.25); + border: 3px solid var(--panel-border); + color: var(--text); + font-family: inherit; + font-size: 10px; + line-height: 1.5; + text-align: left; + cursor: pointer; +} + +.upgrade-btn:hover:not(:disabled) { + border-color: var(--accent); +} + +.upgrade-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.upgrade-btn__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.upgrade-btn__level { + color: var(--accent); + flex-shrink: 0; +} + +.upgrade-btn__effects { + display: flex; + align-items: center; + gap: 6px; + color: #cbd5c0; +} + +.upgrade-btn__arrow { + color: var(--accent); +} + +.upgrade-btn__cost { + display: flex; + align-items: center; + gap: 4px; + margin-top: 2px; + color: #f5c518; +} + +.game-map { + position: relative; + flex-grow: 1; + overflow: hidden; + background-color: #4caf50; + background-image: + linear-gradient(45deg, #46a047 25%, transparent 25%), + linear-gradient(-45deg, #46a047 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #46a047 75%), + linear-gradient(-45deg, transparent 75%, #46a047 75%); + background-size: 32px 32px; + background-position: 0 0, 0 16px, 16px -16px, -16px 0px; + cursor: default; + touch-action: none; + user-select: none; +} + +.entity { + position: absolute; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.entity.log-shadow { + width: 20px; + height: 10px; + margin: 8px 0px 0px 0px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.35); + z-index: 0; +} + +.entity.tree-shadow { + width: 64px; + height: 32px; + border-radius: 50%; + margin: 26px 0px 0px -11px; + background: rgba(0, 0, 0, 0.35); + z-index: 0; +} + +.entity.character-shadow { + width: 48px; + height: 24px; + border-radius: 50%; + margin: 32px 0px 0px 0px; + background: rgba(0, 0, 0, 0.35); + z-index: 0; +} + +.entity.tree { + pointer-events: auto; + cursor: pointer; + padding: 12px; + margin: -12px; +} + +.entity.tree.tree--chopping svg { + animation: chop-shake 0.12s infinite; +} + +.entity.character { + transition: none; +} + +.entity.character.character--walking svg { + animation: walk-bob 0.3s infinite steps(2); +} + +@keyframes chop-shake { + 0%, 100% { + transform: translate(0, 0) rotate(0deg); + } + 25% { + transform: translate(-2px, 1px) rotate(-6deg); + } + 50% { + transform: translate(1px, -2px) rotate(5deg); + } + 75% { + transform: translate(-1px, 1px) rotate(-4deg); + } +} + +@keyframes walk-bob { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(-3px); + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..86cd591 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,22 @@ +import { useState } from 'react'; +import GameMap from './components/GameMap'; +import Sidebar from './components/Sidebar'; +import Topbar from './components/Topbar'; +import { GameStore } from './game/GameStore'; +import './App.css'; + +function App() { + const [store] = useState(() => new GameStore()); + + return ( +
+ +
+ + +
+
+ ); +} + +export default App; diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/components/GameMap.tsx b/src/components/GameMap.tsx new file mode 100644 index 0000000..b3d2b75 --- /dev/null +++ b/src/components/GameMap.tsx @@ -0,0 +1,159 @@ +import { observer } from 'mobx-react-lite'; +import { useEffect, useRef, useState } from 'react'; +import type { PointerEvent } from 'react'; +import type { GameStore } from '../game/GameStore'; +import type { LogDrop, TreeState } from '../game/types'; +import { CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE } from '../game/sprites'; +import PixelSprite from './PixelSprite'; + +interface Props { + store: GameStore; +} + +// Each entity below is its own observer so mobx can subscribe it only to the fields it actually +// reads. Otherwise, with everything read inside GameMap's single observer, the character moving +// every animation frame would re-render the whole tree/log list too, not just itself. + +const LogEntity = observer(function LogEntity({ log }: { log: LogDrop }) { + return ( +
+
+
+ +
+
+ ); +}); + +const TreeEntity = observer(function TreeEntity({ + tree, + store, + onPointerDown, + onPointerUp, +}: { + tree: TreeState; + store: GameStore; + onPointerDown: (e: PointerEvent, id: string) => void; + onPointerUp: (e: PointerEvent) => void; +}) { + const isChopping = tree.id === store.heldTreeId && store.isChopping; + + return ( +
+
+
onPointerDown(e, tree.id)} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + > + +
+
+ ); +}); + +const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) { + return ( + <> +
+
+ +
+ + ); +}); + +function GameMap({ store }: Props) { + const containerRef = useRef(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + const container = containerRef.current; + if (!container) + return; + + const { width, height } = container.getBoundingClientRect(); + if (width > 0 && height > 0) { + store.initialize(width, height); + store.start(); + setReady(true); + } + + return () => { + store.stop(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + window.addEventListener('blur', store.release); + window.addEventListener('beforeunload', store.save); + return () => { + window.removeEventListener('blur', store.release); + window.removeEventListener('beforeunload', store.save); + }; + }, [store]); + + function handleTreePointerDown(e: PointerEvent, id: string) { + if (e.button !== 0) + return; + // Stop this from also bubbling to the map's own pointer handlers below (which would + // otherwise interpret the same press as "walk to this empty ground point"). + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + store.holdTree(id); + } + + function handleTreePointerUp(e: PointerEvent) { + e.stopPropagation(); + store.release(); + } + + function handleMapPointerDown(e: PointerEvent) { + if (e.button !== 0) + return; + const rect = e.currentTarget.getBoundingClientRect(); + e.currentTarget.setPointerCapture(e.pointerId); + store.moveTo(e.clientX - rect.left, e.clientY - rect.top); + } + + return ( +
+ {ready && ( + <> + {store.logDrops.map((log) => ( + + ))} + + {store.trees.map((tree) => ( + + ))} + + + + )} +
+ ); +} + +export default observer(GameMap); diff --git a/src/components/PixelSprite.tsx b/src/components/PixelSprite.tsx new file mode 100644 index 0000000..f82c418 --- /dev/null +++ b/src/components/PixelSprite.tsx @@ -0,0 +1,35 @@ +import { memo } from 'react'; +import type { PixelGrid } from '../game/sprites'; + +interface Props { + pixels: PixelGrid; + size: number; + className?: string; +} + +function PixelSprite({ pixels, size, className }: Props) { + const rows = pixels.length; + const cols = pixels[0]?.length ?? 0; + + return ( + 0 ? size * (rows / cols) : size} + viewBox={`0 0 ${cols} ${rows}`} + shapeRendering="crispEdges" + > + {pixels.flatMap((row, y) => + row.map((color, x) => + color ? : null, + ), + )} + + ); +} + +// `pixels` is always one of the module-level sprite constants and `size` is a literal passed by +// the caller, so both are reference/value-stable across re-renders — memoizing means an entity +// re-rendering every frame for its position (left/top) doesn't also regenerate this sprite's +// (potentially hundreds of) elements each time. +export default memo(PixelSprite); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..e61e84f --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,50 @@ +import { observer } from 'mobx-react-lite'; +import type { GameStore } from '../game/GameStore'; +import { costForNextLevel, UPGRADE_LIST } from '../game/upgrades'; +import { COIN_SPRITE } from '../game/sprites'; +import PixelSprite from './PixelSprite'; + +interface Props { + store: GameStore; +} + +function Sidebar({ store }: Props) { + return ( +
+

Апгрейды

+
+ {UPGRADE_LIST.map((def) => { + const level = store.upgradeLevels[def.id]; + const cost = costForNextLevel(def, level); + const canAfford = store.inventory.coins >= cost; + + return ( + + ); + })} +
+
+ ); +} + +export default observer(Sidebar); diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx new file mode 100644 index 0000000..b8a476f --- /dev/null +++ b/src/components/Topbar.tsx @@ -0,0 +1,27 @@ +import { observer } from 'mobx-react-lite'; +import type { GameStore } from '../game/GameStore'; +import { COIN_SPRITE, LOG_SPRITE } from '../game/sprites'; +import PixelSprite from './PixelSprite'; + +interface Props { + store: GameStore; +} + +function Topbar({ store }: Props) { + return ( +
+

Forest

+
+
+ + {store.inventory.logs} +
+
+ + {store.inventory.coins} +
+
+ ); +} + +export default observer(Topbar); diff --git a/src/game/GameStore.ts b/src/game/GameStore.ts new file mode 100644 index 0000000..f1297bd --- /dev/null +++ b/src/game/GameStore.ts @@ -0,0 +1,405 @@ +import { makeAutoObservable } from 'mobx'; +import type { Inventory, LogDrop, TreeState, Vec2 } from './types'; +import { + INTERACTION_RADIUS, + LOG_COLLECT_ARC_HEIGHT, + LOG_COLLECT_DURATION, + LOG_EJECT_RADIUS, + LOG_FALL_ARC_HEIGHT, + LOG_FALL_DURATION, + LOGS_PER_TREE, + TREE_COUNT, + TREE_EDGE_MARGIN, + TREE_MIN_DISTANCE_BETWEEN, + TREE_MIN_DISTANCE_FROM_CENTER, +} from './constants'; +import { loadSave, writeSave } from './persistence'; +import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades'; + +/** How often (seconds of played time) the game autosaves while running. */ +const AUTOSAVE_INTERVAL = 2; + +function randomId(prefix: string): string { + return `${prefix}-${Math.random().toString(36).slice(2)}`; +} + +export class GameStore { + character: Vec2 = { x: 0, y: 0 }; + trees: TreeState[] = []; + logDrops: LogDrop[] = []; + inventory: Inventory = { logs: 0, coins: 0 }; + heldTreeId: string | null = null; + /** Free-form walk destination set by pressing on empty map, mutually exclusive with heldTreeId. */ + moveTarget: Vec2 | null = null; + upgradeLevels: Record = { + moveSpeed: 1, + chopSpeed: 1, + logValue: 1, + pickupRadius: 1, + coinSpeed: 1, + }; + + private chopProgress = 0; + private coinProgress = 0; + private saveProgress = 0; + private lastTimestamp: number | null = null; + private rafId: number | null = null; + + constructor() { + // autoBind so methods stay correctly bound when passed as bare references (event handlers, + // e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`). + // Deliberately no side effects (e.g. global event listener registration) here beyond + // makeAutoObservable: React StrictMode double-invokes `useState(() => new GameStore())`'s + // initializer in dev, constructing (and discarding) an extra instance — any listener + // registered in the constructor would leak and fire for that discarded instance too. Global + // listeners like beforeunload belong in a useEffect instead (see GameMap), which StrictMode + // handles correctly via its mount/cleanup/remount cycle. + makeAutoObservable(this, {}, { autoBind: true }); + } + + get heldTree(): TreeState | undefined { + return this.trees.find((tree) => tree.id === this.heldTreeId); + } + + /** Whether the character is currently in chopping range of the held tree (as opposed to still walking to it). */ + get isChopping(): boolean { + const tree = this.heldTree; + if (!tree) + return false; + return Math.hypot(tree.x - this.character.x, tree.y - this.character.y) <= INTERACTION_RADIUS; + } + + /** Whether the character is actively walking (toward a held tree or a free-form move target). */ + get isMoving(): boolean { + if (this.heldTreeId) + return !this.isChopping; + if (this.moveTarget) + return Math.hypot(this.moveTarget.x - this.character.x, this.moveTarget.y - this.character.y) > 0.5; + return false; + } + + get characterSpeed(): number { + return UPGRADES.moveSpeed.effectAtLevel(this.upgradeLevels.moveSpeed); + } + + get chopInterval(): number { + const logsPerSecond = UPGRADES.chopSpeed.effectAtLevel(this.upgradeLevels.chopSpeed); + return 1 / logsPerSecond; + } + + get coinsPerLog(): number { + return UPGRADES.logValue.effectAtLevel(this.upgradeLevels.logValue); + } + + get pickupRadiusPx(): number { + return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius); + } + + get coinConversionInterval(): number { + const logsPerSecond = UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed); + return 1 / logsPerSecond; + } + + /** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */ + buyUpgrade(id: UpgradeId): boolean { + const level = this.upgradeLevels[id]; + const cost = costForNextLevel(UPGRADES[id], level); + if (this.inventory.coins < cost) + return false; + + this.inventory.coins -= cost; + this.upgradeLevels[id] = level + 1; + return true; + } + + /** (Re)initializes the game for a map of the given size. Restores a saved game (character/trees/ + * logs/progress) if one exists; otherwise places the character in the center and scatters + * fresh trees. */ + initialize(width: number, height: number) { + this.heldTreeId = null; + this.moveTarget = null; + this.chopProgress = 0; + this.coinProgress = 0; + this.saveProgress = 0; + + const save = loadSave(); + if (save?.character && save.trees && save.logDrops) { + this.character = { ...save.character }; + this.trees = save.trees.map((tree) => ({ ...tree })); + this.logDrops = save.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined })); + } else { + this.character = { x: width / 2, y: height / 2 }; + this.trees = this.spawnTrees(width, height); + this.logDrops = []; + } + + this.inventory = save ? { ...save.inventory } : { logs: 0, coins: 0 }; + if (save) + this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels }; + } + + /** Persists the full game (world state + progress) to localStorage right away. */ + save() { + writeSave({ + version: 1, + inventory: { ...this.inventory }, + upgradeLevels: { ...this.upgradeLevels }, + character: { ...this.character }, + trees: this.trees.map((tree) => ({ ...tree })), + logDrops: this.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined })), + }); + } + + private spawnTrees(width: number, height: number): TreeState[] { + const centerX = width / 2; + const centerY = height / 2; + const trees: TreeState[] = []; + let attempts = 0; + + while (trees.length < TREE_COUNT && attempts < 1000) { + attempts++; + const x = TREE_EDGE_MARGIN + Math.random() * Math.max(0, width - TREE_EDGE_MARGIN * 2); + const y = TREE_EDGE_MARGIN + Math.random() * Math.max(0, height - TREE_EDGE_MARGIN * 2); + + if (Math.hypot(x - centerX, y - centerY) < TREE_MIN_DISTANCE_FROM_CENTER) + continue; + if (trees.some((tree) => Math.hypot(tree.x - x, tree.y - y) < TREE_MIN_DISTANCE_BETWEEN)) + continue; + + trees.push({ id: randomId('tree'), x, y, logsRemaining: LOGS_PER_TREE }); + } + + return trees; + } + + holdTree(id: string) { + if (this.trees.some((tree) => tree.id === id)) { + this.moveTarget = null; + this.heldTreeId = id; + } + } + + /** Starts walking freely toward an arbitrary point on the map (e.g. a click on empty ground). */ + moveTo(x: number, y: number) { + this.heldTreeId = null; + this.chopProgress = 0; + this.moveTarget = { x, y }; + } + + release() { + this.heldTreeId = null; + this.moveTarget = null; + this.chopProgress = 0; + } + + start() { + if (this.rafId !== null) + return; + this.lastTimestamp = null; + + const loop = (timestamp: number) => { + const last = this.lastTimestamp ?? timestamp; + // Cap the per-frame step so a stalled/backgrounded tab doesn't cause a huge catch-up jump + // (e.g. character teleporting across the map) once it resumes. + const deltaTime = Math.min((timestamp - last) / 1000, 0.1); + this.lastTimestamp = timestamp; + this.tick(deltaTime); + this.rafId = requestAnimationFrame(loop); + }; + this.rafId = requestAnimationFrame(loop); + } + + stop() { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + this.lastTimestamp = null; + } + + private tick(deltaTime: number) { + this.updateMovementAndChopping(deltaTime); + this.updateLogFlights(deltaTime); + this.updatePickups(); + this.updateCoinConversion(deltaTime); + this.updateAutosave(deltaTime); + } + + private updateAutosave(deltaTime: number) { + this.saveProgress += deltaTime; + if (this.saveProgress < AUTOSAVE_INTERVAL) + return; + this.saveProgress = 0; + this.save(); + } + + private updateMovementAndChopping(deltaTime: number) { + const tree = this.heldTree; + if (tree) { + this.walkTowardTree(tree, deltaTime); + return; + } + + if (this.moveTarget) { + this.walkTowardPoint(this.moveTarget, deltaTime); + return; + } + + this.chopProgress = 0; + } + + private walkTowardPoint(target: Vec2, deltaTime: number) { + const dx = target.x - this.character.x; + const dy = target.y - this.character.y; + const distance = Math.hypot(dx, dy); + + if (distance <= 0.5) { + this.character = { x: target.x, y: target.y }; + return; + } + + const step = Math.min(this.characterSpeed * deltaTime, distance); + const ratio = step / distance; + this.character = { + x: this.character.x + dx * ratio, + y: this.character.y + dy * ratio, + }; + } + + private walkTowardTree(tree: TreeState, deltaTime: number) { + const dx = tree.x - this.character.x; + const dy = tree.y - this.character.y; + const distance = Math.hypot(dx, dy); + + // A small tolerance keeps the character from flip-flopping between "walking" and "chopping" + // every frame once it's essentially at the interaction radius, which floating-point error on + // the two-axis step below can otherwise cause (each flip resets chopProgress, so chopping + // could stall forever right at the boundary). + if (distance > INTERACTION_RADIUS + 0.5) { + const step = Math.min(this.characterSpeed * deltaTime, distance - INTERACTION_RADIUS); + const ratio = step / distance; + this.character = { + x: this.character.x + dx * ratio, + y: this.character.y + dy * ratio, + }; + this.chopProgress = 0; + return; + } + + const chopInterval = this.chopInterval; + this.chopProgress += deltaTime; + while (this.chopProgress >= chopInterval && tree.logsRemaining > 0) { + this.chopProgress -= chopInterval; + tree.logsRemaining -= 1; + + // Eject the log on a ballistic arc to a random spot within a circle around the tree; it + // only becomes pickable once it lands (see updateLogFlights/updatePickups). + const angle = Math.random() * Math.PI * 2; + const landDistance = Math.random() * LOG_EJECT_RADIUS; + const toX = tree.x + Math.cos(angle) * landDistance; + const toY = tree.y + Math.sin(angle) * landDistance; + this.logDrops.push({ + id: randomId('log'), + x: tree.x, + y: tree.y, + z: 0, + phase: 'falling', + flight: { + fromX: tree.x, + fromY: tree.y, + toX, + toY, + elapsed: 0, + duration: LOG_FALL_DURATION, + arcHeight: LOG_FALL_ARC_HEIGHT, + }, + }); + + if (tree.logsRemaining <= 0) { + this.trees = this.trees.filter((t) => t.id !== tree.id); + this.heldTreeId = null; + this.chopProgress = 0; + break; + } + } + } + + /** Advances every log currently in a ballistic flight (falling from a tree, or flying into the + * inventory once collected), landing/collecting it once its flight completes. Mutates logs in + * place and only reassigns the `logDrops` array when one is actually removed (collected) — a + * landed log sitting idle, or the array being rebuilt every tick regardless, would otherwise + * force every observer of `logDrops` (i.e. the whole map) to re-render every animation frame. */ + private updateLogFlights(deltaTime: number) { + if (this.logDrops.length === 0) + return; + + let collectedIds: Set | null = null; + for (const log of this.logDrops) { + if (log.phase === 'landed') + continue; + + const flight = log.flight!; + flight.elapsed += deltaTime; + const t = Math.min(1, flight.elapsed / flight.duration); + log.x = flight.fromX + (flight.toX - flight.fromX) * t; + log.y = flight.fromY + (flight.toY - flight.fromY) * t; + log.z = flight.arcHeight * 4 * t * (1 - t); + + if (t < 1) + continue; + + if (log.phase === 'falling') { + log.phase = 'landed'; + log.flight = undefined; + log.z = 0; + } else { + // Collecting flight finished: the log has reached the character. + (collectedIds ??= new Set()).add(log.id); + this.inventory.logs += 1; + } + } + + if (collectedIds) + this.logDrops = this.logDrops.filter((log) => !collectedIds!.has(log.id)); + } + + /** Starts a landed log flying into the inventory once the character gets close enough. */ + private updatePickups() { + const pickupRadius = this.pickupRadiusPx; + for (const log of this.logDrops) { + if (log.phase !== 'landed') + continue; + + const distance = Math.hypot(log.x - this.character.x, log.y - this.character.y); + if (distance > pickupRadius) + continue; + + log.phase = 'collecting'; + log.flight = { + fromX: log.x, + fromY: log.y, + toX: this.character.x, + toY: this.character.y, + elapsed: 0, + duration: LOG_COLLECT_DURATION, + arcHeight: LOG_COLLECT_ARC_HEIGHT, + }; + } + } + + private updateCoinConversion(deltaTime: number) { + if (this.inventory.logs <= 0) { + this.coinProgress = 0; + return; + } + + const coinsPerLog = this.coinsPerLog; + const conversionInterval = this.coinConversionInterval; + this.coinProgress += deltaTime; + while (this.coinProgress >= conversionInterval && this.inventory.logs > 0) { + this.coinProgress -= conversionInterval; + this.inventory.logs -= 1; + this.inventory.coins += coinsPerLog; + } + } +} diff --git a/src/game/constants.ts b/src/game/constants.ts new file mode 100644 index 0000000..cd66a79 --- /dev/null +++ b/src/game/constants.ts @@ -0,0 +1,29 @@ +export const TREE_COUNT = 5; +export const LOGS_PER_TREE = 5; + +/** How close the character must get to a tree's center to start chopping it. */ +export const INTERACTION_RADIUS = 56; + +/** Radius of the circle around a tree that ejected logs can land in. */ +export const LOG_EJECT_RADIUS = 70; + +/** Seconds for a log's ballistic flight from the tree to its landing spot. */ +export const LOG_FALL_DURATION = 0.5; + +/** Peak height (px) of a log's arc while falling from the tree. */ +export const LOG_FALL_ARC_HEIGHT = 40; + +/** Seconds for a landed log's flight into the character's inventory. */ +export const LOG_COLLECT_DURATION = 0.25; + +/** Peak height (px) of a log's little hop while flying into the inventory. */ +export const LOG_COLLECT_ARC_HEIGHT = 16; + +/** Trees won't spawn closer than this to the character's starting position. */ +export const TREE_MIN_DISTANCE_FROM_CENTER = 140; + +/** Trees won't spawn closer than this to each other. */ +export const TREE_MIN_DISTANCE_BETWEEN = 100; + +/** Trees won't spawn within this many px of the map edge. */ +export const TREE_EDGE_MARGIN = 70; diff --git a/src/game/persistence.ts b/src/game/persistence.ts new file mode 100644 index 0000000..68f2ead --- /dev/null +++ b/src/game/persistence.ts @@ -0,0 +1,51 @@ +import type { Inventory, LogDrop, TreeState, Vec2 } from './types'; +import type { UpgradeId } from './upgrades'; + +const STORAGE_KEY = 'forest-save-v1'; + +export interface SaveData { + version: 1; + inventory: Inventory; + upgradeLevels: Record; + /** World state (character/trees/logs) is optional so older saves without it still load fine — + * progress is restored either way, just with a freshly spawned world. */ + character?: Vec2; + trees?: TreeState[]; + logDrops?: LogDrop[]; +} + +function isValidSave(value: unknown): value is SaveData { + if (!value || typeof value !== 'object') + return false; + const data = value as Partial; + return ( + data.version === 1 + && typeof data.inventory === 'object' && data.inventory !== null + && typeof data.inventory.logs === 'number' + && typeof data.inventory.coins === 'number' + && typeof data.upgradeLevels === 'object' && data.upgradeLevels !== null + ); +} + +/** Reads the saved game from localStorage, or null if there's none / it's corrupted. */ +export function loadSave(): SaveData | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) + return null; + const parsed: unknown = JSON.parse(raw); + return isValidSave(parsed) ? parsed : null; + } catch { + // localStorage unavailable (private mode, disabled) or corrupted JSON — treat as no save. + return null; + } +} + +/** Writes the save to localStorage. Best-effort: silently no-ops if storage is unavailable/full. */ +export function writeSave(data: SaveData): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch { + // Ignore — e.g. private browsing mode or quota exceeded. + } +} diff --git a/src/game/sprites.ts b/src/game/sprites.ts new file mode 100644 index 0000000..2c045ee --- /dev/null +++ b/src/game/sprites.ts @@ -0,0 +1,135 @@ +/** Pixel grids for 8-bit-style sprites. Each cell is a CSS color, or '' for transparent. */ +export type PixelGrid = string[][]; + +function grid(rows: string[], palette: Record): PixelGrid { + return rows.map((row) => [...row].map((ch) => palette[ch] ?? '')); +} + +const CHARACTER_PALETTE = { + O: '#1b1611', + H: '#4a2f1c', + S: '#f0c090', + s: '#d9a873', + B: '#3f6fb0', + b: '#2f5488', + P: '#2c2c3e', + p: '#20202e', + F: '#2a1f14', +}; + +export const CHARACTER_SPRITE: PixelGrid = grid( + [ + '....OOOOOOOO....', + '..OHHHHHHHHHHO..', + '.OHHHHHHHHHHHHO.', + 'OHHHHHHHHHHHHHHO', + 'OHHSSSSSSSSSSHHO', + 'OSSSSOSSSSOSSSSO', + 'OSSSSSSssSSSSSSO', + '.OSSSSSSSSSSSSO.', + '......OSSO......', + '...OBBBBBBBBO...', + '..OBBBBBBBBBBO..', + '.OSBBBBBBBBBBSO.', + '.OSBBbBBBBbBBSO.', + '.OSBBbBBBBbBBSO.', + '..OBBbBBBBbBBO..', + '..OPPpPPPPpPPO..', + '..OPPpPPPPpPPO..', + '..OPPpO..OpPPO..', + '..OPPpO..OpPPO..', + '..OPPpO..OpPPO..', + '..OFFFO..OFFFO..', + '.OFFFFO..OFFFFO.', + ], + CHARACTER_PALETTE, +); + +const TREE_PALETTE = { + O: '#14290f', + G: '#2e7d32', + g: '#1b5e20', + H: '#66bb6a', + T: '#6b4226', + t: '#4e2f1a', + n: '#8a5a34', +}; + +export const TREE_SPRITE: PixelGrid = grid( + [ + '......OggO......', + '....OGGggGGO....', + '..OGGGGggGGGGO..', + '.OGgGGGHHGGGgGO.', + 'OGgGGGGHHGGGGgGO', + 'OGGgGGGggGGGgGGO', + 'OGgGGGgGGgGGGgGO', + 'OGGgGGGGGGGGgGGO', + 'OGgGGGGggGGGGgGO', + '.OGgGGGggGGGgGO.', + '..OGgGGggGGgGO..', + '...OGgGggGgGO...', + '....OGgGGgGO....', + '......OggO......', + '......OTTO......', + '......TTTT......', + '......TttT......', + '......tnnt......', + '......TTTT......', + '......TOOT......', + ], + TREE_PALETTE, +); + +const LOG_PALETTE = { + O: '#2a1a0d', + B: '#7a4a2b', + C: '#a9703f', +}; + +export const LOG_SPRITE: PixelGrid = grid( + [ + '..OOOOOOOOOOOOOOOO..', + 'OBBBBBBBBBBBBBBBBBBO', + 'OBCCCCCCCCCCCCCCCCBO', + 'OBBBBBBBBBBBBBBBBBBO', + 'OBCCCCCCCCCCCCCCCCBO', + 'OBBBBBBBBBBBBBBBBBBO', + 'OBCCCCCCCCCCCCCCCCBO', + 'OBBBBBBBBBBBBBBBBBBO', + 'OBCCCCCCCCCCCCCCCCBO', + 'OBBBBBBBBBBBBBBBBBBO', + 'OBCCCCCCCCCCCCCCCCBO', + '..OOOOOOOOOOOOOOOO..', + ], + LOG_PALETTE, +); + +const COIN_PALETTE = { + Y: '#f5c518', + D: '#c99a0e', + W: '#fff2b0', + E: '#a97c08', +}; + +export const COIN_SPRITE: PixelGrid = grid( + [ + '.....YYYYYY.....', + '...YYYYYYYYYY...', + '..YWWYYYYYYYYY..', + '.YYWWYYYYYYYYYY.', + '.YYYYYYYYYYYYYY.', + 'YYYYYDDDDDDYYYYY', + 'YYYYYDYYYYDYYYYY', + 'YYYYYDYYYYDYYYYY', + 'YYYYYDYYYYDYYYYY', + 'YYYYYDYYYYDYYYYY', + 'YYYYYDDDDDDYYYYY', + '.YYYYYYYYYYYYYY.', + '.YYYYYYYYYYEEYY.', + '..YYYYYYYYYEEY..', + '...YYYYYYYYYY...', + '.....YYYYYY.....', + ], + COIN_PALETTE, +); diff --git a/src/game/types.ts b/src/game/types.ts new file mode 100644 index 0000000..eee8eb0 --- /dev/null +++ b/src/game/types.ts @@ -0,0 +1,38 @@ +export interface Vec2 { + x: number; + y: number; +} + +export interface TreeState { + id: string; + x: number; + y: number; + logsRemaining: number; +} + +export type LogPhase = 'falling' | 'landed' | 'collecting'; + +export interface LogFlight { + fromX: number; + fromY: number; + toX: number; + toY: number; + elapsed: number; + duration: number; + arcHeight: number; +} + +export interface LogDrop { + id: string; + x: number; + y: number; + /** Visual height above ground during a flight, for the ballistic arc. 0 once landed. */ + z: number; + phase: LogPhase; + flight?: LogFlight; +} + +export interface Inventory { + logs: number; + coins: number; +} diff --git a/src/game/upgrades.ts b/src/game/upgrades.ts new file mode 100644 index 0000000..5eeca68 --- /dev/null +++ b/src/game/upgrades.ts @@ -0,0 +1,63 @@ +export type UpgradeId = 'moveSpeed' | 'chopSpeed' | 'logValue' | 'pickupRadius' | 'coinSpeed'; + +export interface UpgradeDef { + id: UpgradeId; + name: string; + baseCost: number; + costGrowth: number; + /** The upgrade's stat value at a given level (levels start at 1, unbounded above). */ + effectAtLevel: (level: number) => number; + formatEffect: (value: number) => string; +} + +export const UPGRADE_LIST: UpgradeDef[] = [ + { + id: 'moveSpeed', + name: 'Скорость движения', + baseCost: 5, + costGrowth: 1.35, + effectAtLevel: (level) => 160 + (level - 1) * 20, + formatEffect: (v) => `${Math.round(v)} px/с`, + }, + { + id: 'chopSpeed', + name: 'Скорость рубки', + baseCost: 6, + costGrowth: 1.35, + effectAtLevel: (level) => (1 / 0.6) * Math.pow(1.15, level - 1), + formatEffect: (v) => `${v.toFixed(2)} бревен/с`, + }, + { + id: 'logValue', + name: 'Стоимость бревна', + baseCost: 8, + costGrowth: 1.4, + effectAtLevel: (level) => level, + formatEffect: (v) => `${v} монет/бревно`, + }, + { + id: 'pickupRadius', + name: 'Радиус подбора', + baseCost: 5, + costGrowth: 1.3, + effectAtLevel: (level) => 46 + (level - 1) * 6, + formatEffect: (v) => `${Math.round(v)} px`, + }, + { + id: 'coinSpeed', + name: 'Скорость обмена на монеты', + baseCost: 7, + costGrowth: 1.35, + effectAtLevel: (level) => Math.pow(1.15, level - 1), + formatEffect: (v) => `${v.toFixed(2)} бревен/с`, + }, +]; + +export const UPGRADES: Record = Object.fromEntries( + UPGRADE_LIST.map((def) => [def.id, def]), +) as Record; + +/** Cost (in coins) to go from `currentLevel` to `currentLevel + 1`. */ +export function costForNextLevel(def: UpgradeDef, currentLevel: number): number { + return Math.ceil(def.baseCost * Math.pow(def.costGrowth, currentLevel - 1)); +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..aef8a2a --- /dev/null +++ b/src/index.css @@ -0,0 +1,25 @@ +:root { + --panel-bg: #3a2a1e; + --panel-border: #1b1611; + --text: #f5e9d0; + --accent: #f5c518; + + font: 16px/1.4 'Press Start 2P', ui-monospace, Consolas, monospace; + color: var(--text); + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + overflow: hidden; +} + +#root { + height: 100%; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9104d77 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +const rootDir = path.dirname(fileURLToPath(import.meta.url)) +const engineDir = path.resolve(rootDir, '../engine') + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + // Follow the node_modules symlink to the real sibling directory, so Vite's dev server + // watches and transforms the actual engine source files (not a frozen pre-bundled copy). + preserveSymlinks: false, + }, + optimizeDeps: { + // Treat @incgame/engine as first-party source, not a vendor dependency to pre-bundle once + // and cache — otherwise edits to the engine package wouldn't trigger HMR until a full + // dev-server restart. + exclude: ['@incgame/engine'], + }, + server: { + host: '0.0.0.0', + fs: { + // The engine package lives outside this project's root (a sibling directory reached + // through a node_modules symlink); allow Vite to serve/watch files from there too. + allow: [rootDir, engineDir], + }, + }, +})