логика выделена в отдельный набор классов
This commit is contained in:
Generated
+504
@@ -20,6 +20,7 @@
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
@@ -36,6 +37,448 @@
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
|
||||
@@ -714,6 +1157,48 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1237,6 +1722,25 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.13",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
|
||||
+3
-1
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"sim": "tsx scripts/simulate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"break_eternity.js": "^2.1.3",
|
||||
@@ -22,6 +23,7 @@
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Headless example: plays the game for many ticks with no browser/DOM involved at all — proves
|
||||
* GameStore/its entities (see src/game/entities) can run standalone, and demonstrates measuring
|
||||
* how often a player would need to act (pick a new target) per unit of simulated time.
|
||||
*
|
||||
* Run with: npx tsx scripts/simulate.ts [ticks] [tickSeconds]
|
||||
*/
|
||||
import { Rabbit } from '../src/game/entities/Rabbit';
|
||||
import { ResourceNode } from '../src/game/entities/ResourceNode';
|
||||
import { GameStore } from '../src/game/GameStore';
|
||||
|
||||
const totalTicks = Number(process.argv[2] ?? 1_000_000);
|
||||
const tickSeconds = Number(process.argv[3] ?? 0.1);
|
||||
const mapWidth = 1200;
|
||||
const mapHeight = 800;
|
||||
|
||||
const store = new GameStore();
|
||||
store.initialize(mapWidth, mapHeight);
|
||||
|
||||
// A minimal bot policy standing in for a player: keep whatever target is currently held as long
|
||||
// as it still exists; otherwise pick the nearest node or rabbit and hold it. Each such pick is
|
||||
// counted as one "user action" — a mouse click choosing a new thing to interact with.
|
||||
let actionCount = 0;
|
||||
|
||||
for (let i = 0; i < totalTicks; i++) {
|
||||
if (store.isDead)
|
||||
break;
|
||||
|
||||
const stillHoldingNode = store.heldNodeId !== null && store.nodes.some((node) => node.id === store.heldNodeId);
|
||||
const stillHoldingRabbit = store.heldRabbitId !== null && store.rabbits.some((rabbit) => rabbit.id === store.heldRabbitId);
|
||||
|
||||
if (!stillHoldingNode && !stillHoldingRabbit) {
|
||||
const nearestNode = ResourceNode.findNearestToPosition(store.nodes, store.character);
|
||||
const nearestRabbit = Rabbit.findNearestToPosition(store.rabbits, store.character);
|
||||
const nodeDistance = nearestNode ? Math.hypot(nearestNode.x - store.character.x, nearestNode.y - store.character.y) : Infinity;
|
||||
const rabbitDistance = nearestRabbit ? Math.hypot(nearestRabbit.x - store.character.x, nearestRabbit.y - store.character.y) : Infinity;
|
||||
|
||||
if (nearestNode && nodeDistance <= rabbitDistance) {
|
||||
store.holdNode(nearestNode.id);
|
||||
actionCount++;
|
||||
} else if (nearestRabbit) {
|
||||
store.holdRabbit(nearestRabbit.id);
|
||||
actionCount++;
|
||||
}
|
||||
}
|
||||
|
||||
store.tick(tickSeconds);
|
||||
}
|
||||
|
||||
const simulatedSeconds = totalTicks * tickSeconds;
|
||||
console.log(JSON.stringify({
|
||||
ticks: totalTicks,
|
||||
simulatedDay: Number((store.day + store.hour / 24).toFixed(2)),
|
||||
isDead: store.isDead,
|
||||
inventory: store.inventory,
|
||||
actionCount,
|
||||
actionsPerSimulatedMinute: Number((actionCount / (simulatedSeconds / 60)).toFixed(3)),
|
||||
}, null, 2));
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import type { ResourceNode } from '../game/types';
|
||||
import type { ResourceNode } from '../game/entities/ResourceNode';
|
||||
import { BUSH_SPRITE, TREE_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import type { RabbitState } from '../game/types';
|
||||
import type { Rabbit } from '../game/entities/Rabbit';
|
||||
import { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../game/sprites';
|
||||
import HealthBar from './HealthBar';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
const RABBIT_SIZE = 30;
|
||||
|
||||
const RabbitEntity = observer(function RabbitEntity({ rabbit, store }: { rabbit: RabbitState; store: GameStore }) {
|
||||
const RabbitEntity = observer(function RabbitEntity({ rabbit, store }: { rabbit: Rabbit; store: GameStore }) {
|
||||
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { ResourceDrop } from '../game/types';
|
||||
import type { ResourceDrop } from '../game/entities/ResourceDrop';
|
||||
import { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
|
||||
+154
-448
@@ -1,48 +1,29 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import type { Inventory, NodeResourceType, RabbitState, ResourceDrop, ResourceNode, ResourceType, Vec2 } from './types';
|
||||
import {
|
||||
bushesForDay,
|
||||
DROP_COLLECT_ARC_HEIGHT,
|
||||
DROP_COLLECT_DURATION,
|
||||
DROP_EJECT_RADIUS,
|
||||
DROP_FALL_ARC_HEIGHT,
|
||||
DROP_FALL_DURATION,
|
||||
ENTITY_EDGE_MARGIN,
|
||||
ENTITY_MIN_DISTANCE_BETWEEN,
|
||||
ENTITY_MIN_DISTANCE_FROM_CENTER,
|
||||
HIT_FLASH_DURATION,
|
||||
INTERACTION_RADIUS,
|
||||
LOGS_PER_TREE,
|
||||
treesForDay,
|
||||
TWIGS_PER_BUSH,
|
||||
TWIGS_PER_TREE,
|
||||
} from './constants';
|
||||
import { HIT_FLASH_DURATION, INTERACTION_RADIUS, bushesForDay, treesForDay } from './constants';
|
||||
import { BLOOD_MOON_BANNER_SECONDS, DAY_HOURS, isBloodMoonNight, isNightHour, NIGHT_SPEED, nightBelongsToDay } from './daynight';
|
||||
import { Character } from './entities/Character';
|
||||
import { Rabbit } from './entities/Rabbit';
|
||||
import type { RabbitTickContext } from './entities/Rabbit';
|
||||
import { ResourceDrop } from './entities/ResourceDrop';
|
||||
import { ResourceNode } from './entities/ResourceNode';
|
||||
import { clearSave, loadSave, writeSave } from './persistence';
|
||||
import {
|
||||
BLOOD_MOON_COMBAT_MULTIPLIER,
|
||||
BLOOD_MOON_SIZE_MULTIPLIER,
|
||||
RABBIT_ACTION_DURATION,
|
||||
RABBIT_HEALTH_REGEN_INTERVAL,
|
||||
RABBIT_HIT_RADIUS,
|
||||
RABBIT_JUMP_ARC_HEIGHT,
|
||||
RABBIT_JUMP_DISTANCE,
|
||||
RABBIT_MAX_HP,
|
||||
RABBIT_MIN_DISTANCE_FROM_CHARACTER,
|
||||
RABBIT_NIGHT_JUMP_SPREAD_DEGREES,
|
||||
RABBIT_PAUSE_DURATION,
|
||||
RABBIT_STRIKE_DAMAGE,
|
||||
RABBIT_STRIKE_RANGE,
|
||||
rabbitsForDay,
|
||||
} from './rabbits';
|
||||
import { pickSpawnPositions } from './spawning';
|
||||
import type { Inventory, ResourceType, Vec2 } from './types';
|
||||
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
|
||||
|
||||
/** How often (seconds of played time) the game autosaves while running. */
|
||||
const AUTOSAVE_INTERVAL = 2;
|
||||
|
||||
/** A node yields its logs before its twigs. */
|
||||
const RESOURCE_ORDER: NodeResourceType[] = ['log', 'twig'];
|
||||
|
||||
/** Every upgrade starts at level 1 on a fresh game. */
|
||||
const DEFAULT_UPGRADE_LEVELS: Record<UpgradeId, number> = {
|
||||
moveSpeed: 1,
|
||||
@@ -58,31 +39,23 @@ const DEFAULT_UPGRADE_LEVELS: Record<UpgradeId, number> = {
|
||||
healthRegenSpeed: 1,
|
||||
};
|
||||
|
||||
function randomId(prefix: string): string {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function isDepleted(node: ResourceNode): boolean {
|
||||
return RESOURCE_ORDER.every((resource) => node.remaining[resource] <= 0);
|
||||
}
|
||||
|
||||
/** The next resource a node should yield when chopped, or null once it's fully depleted. */
|
||||
function nextResource(node: ResourceNode): NodeResourceType | null {
|
||||
return RESOURCE_ORDER.find((resource) => node.remaining[resource] > 0) ?? null;
|
||||
}
|
||||
|
||||
/** Orchestrates the character, nodes, rabbits, and drops (see ./entities) into one running game.
|
||||
* The only browser-specific pieces are start()/stop() (requestAnimationFrame) and the localStorage
|
||||
* calls inside initialize()/save() (already safe with no localStorage present — see
|
||||
* persistence.ts) — everything else, including tick() itself, is plain computation. A script can
|
||||
* therefore drive a whole game for millions of ticks with no browser involved: construct a
|
||||
* GameStore, call initialize(width, height) once, then call tick(deltaTime) directly in a loop
|
||||
* instead of start(). */
|
||||
export class GameStore {
|
||||
day = 1;
|
||||
/** In-game clock time (0-24, fractional), starts at noon on a fresh game. */
|
||||
hour = 12;
|
||||
character: Vec2 = { x: 0, y: 0 };
|
||||
character: Character = new Character(0, 0, 5);
|
||||
nodes: ResourceNode[] = [];
|
||||
resourceDrops: ResourceDrop[] = [];
|
||||
rabbits: RabbitState[] = [];
|
||||
rabbits: Rabbit[] = [];
|
||||
/** Set once the character's hp reaches 0 — freezes the game (see tick()). */
|
||||
isDead = false;
|
||||
/** Current hp; starts each fresh game at characterMaxHp. */
|
||||
characterHp = 5;
|
||||
/** Seconds left to play the character's hit-shake animation — set only the instant a rabbit's
|
||||
* strike actually lands, not for the whole time it's under threat. */
|
||||
characterHitFlashTimer = 0;
|
||||
@@ -96,9 +69,6 @@ export class GameStore {
|
||||
bloodMoonBannerTimer = 0;
|
||||
upgradeLevels: Record<UpgradeId, number> = { ...DEFAULT_UPGRADE_LEVELS };
|
||||
|
||||
private chopProgress = 0;
|
||||
private rabbitAttackProgress = 0;
|
||||
private healthRegenProgress = 0;
|
||||
private coinProgress = 0;
|
||||
private saveProgress = 0;
|
||||
private lastTimestamp: number | null = null;
|
||||
@@ -121,11 +91,16 @@ export class GameStore {
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
/** Convenience alias for character.hp, since most UI code only cares about the player's own hp. */
|
||||
get characterHp(): number {
|
||||
return this.character.hp;
|
||||
}
|
||||
|
||||
get heldNode(): ResourceNode | undefined {
|
||||
return this.nodes.find((node) => node.id === this.heldNodeId);
|
||||
}
|
||||
|
||||
get heldRabbit(): RabbitState | undefined {
|
||||
get heldRabbit(): Rabbit | undefined {
|
||||
return this.rabbits.find((rabbit) => rabbit.id === this.heldRabbitId);
|
||||
}
|
||||
|
||||
@@ -265,10 +240,6 @@ export class GameStore {
|
||||
this.heldNodeId = null;
|
||||
this.heldRabbitId = null;
|
||||
this.moveTarget = null;
|
||||
this.chopProgress = 0;
|
||||
this.rabbitAttackProgress = 0;
|
||||
this.healthRegenProgress = 0;
|
||||
this.coinProgress = 0;
|
||||
this.saveProgress = 0;
|
||||
this.isDead = false;
|
||||
this.characterHitFlashTimer = 0;
|
||||
@@ -279,55 +250,31 @@ export class GameStore {
|
||||
this.day = save?.day ?? 1;
|
||||
this.hour = save?.hour ?? 12;
|
||||
|
||||
// Always reset to defaults first (not just `if (save)`) — the store instance is reused across
|
||||
// "Новая игра" clicks, so without this a fresh game would inherit upgrade levels bought during
|
||||
// the previous playthrough instead of starting back at level 1. Restored before the world
|
||||
// state below since characterMaxHp/rabbitMaxHp (the fallbacks used there) depend on it.
|
||||
this.upgradeLevels = { ...DEFAULT_UPGRADE_LEVELS, ...save?.upgradeLevels };
|
||||
|
||||
if (save?.character && save.nodes && save.resourceDrops) {
|
||||
this.character = { ...save.character };
|
||||
this.nodes = save.nodes.map((node) => ({ ...node, remaining: { ...node.remaining } }));
|
||||
this.resourceDrops = save.resourceDrops.map((drop) => ({ ...drop, flight: drop.flight ? { ...drop.flight } : undefined }));
|
||||
this.character = new Character(save.character.x, save.character.y, save.characterHp ?? this.characterMaxHp);
|
||||
this.nodes = save.nodes.map((node) => ResourceNode.restore(node));
|
||||
this.resourceDrops = save.resourceDrops.map((drop) => ResourceDrop.restore(drop));
|
||||
} else {
|
||||
this.character = { x: width / 2, y: height / 2 };
|
||||
this.character = new Character(width / 2, height / 2, this.characterMaxHp);
|
||||
this.nodes = this.spawnNodes(width, height);
|
||||
this.resourceDrops = [];
|
||||
}
|
||||
|
||||
// Rabbits are restored/spawned independently of the world-state branch above so a save from
|
||||
// before rabbits existed still loads fine — it just gets a freshly spawned batch of them.
|
||||
// Every field gets an explicit fallback since a save from before the current rabbit shape
|
||||
// (combat hp, or the jump/strike action cycle) won't have all of these.
|
||||
this.rabbits = save?.rabbits
|
||||
? save.rabbits.map((rabbit) => ({
|
||||
id: rabbit.id,
|
||||
x: rabbit.x,
|
||||
y: rabbit.y,
|
||||
// Recomputed from actionTime on the very next tick if actually mid-jump, so 0 is a safe
|
||||
// starting value regardless.
|
||||
z: 0,
|
||||
hp: rabbit.hp ?? this.rabbitMaxHp,
|
||||
// Purely a transient visual cue — always starts unflashed regardless of the save.
|
||||
hitFlashTimer: 0,
|
||||
// Transient accumulator — always starts fresh regardless of the save.
|
||||
healthRegenProgress: 0,
|
||||
action: rabbit.action ?? 'idle',
|
||||
actionTime: rabbit.actionTime ?? 0,
|
||||
jumpFromX: rabbit.jumpFromX ?? rabbit.x,
|
||||
jumpFromY: rabbit.jumpFromY ?? rabbit.y,
|
||||
jumpToX: rabbit.jumpToX ?? rabbit.x,
|
||||
jumpToY: rabbit.jumpToY ?? rabbit.y,
|
||||
}))
|
||||
? save.rabbits.map((rabbit) => Rabbit.restore(rabbit, this.rabbitMaxHp))
|
||||
: this.spawnRabbits(width, height, rabbitsForDay(this.day));
|
||||
|
||||
// Merge over defaults rather than trusting the save's shape outright, so a save from before
|
||||
// meat existed still loads fine (with 0 meat) instead of leaving it undefined.
|
||||
this.inventory = { logs: 0, twigs: 0, meat: 0, coins: 0, ...save?.inventory };
|
||||
// Always reset to defaults first (not just `if (save)`) — the store instance is reused across
|
||||
// "Новая игра" clicks, so without this a fresh game would inherit upgrade levels bought during
|
||||
// the previous playthrough instead of starting back at level 1.
|
||||
this.upgradeLevels = { ...DEFAULT_UPGRADE_LEVELS, ...save?.upgradeLevels };
|
||||
|
||||
// Restored after upgradeLevels above since characterMaxHp (the fallback for a save from
|
||||
// before hp existed, and a fresh game's starting hp) depends on them. Health is allowed to be
|
||||
// fractional (e.g. a rabbit's attack damage no longer rounds to a whole hit), so this doesn't
|
||||
// round either.
|
||||
this.characterHp = save?.characterHp ?? this.characterMaxHp;
|
||||
}
|
||||
|
||||
/** Persists the full game (world state + progress) to localStorage right away. */
|
||||
@@ -336,22 +283,47 @@ export class GameStore {
|
||||
version: 3,
|
||||
inventory: { ...this.inventory },
|
||||
upgradeLevels: { ...this.upgradeLevels },
|
||||
character: { ...this.character },
|
||||
nodes: this.nodes.map((node) => ({ ...node, remaining: { ...node.remaining } })),
|
||||
resourceDrops: this.resourceDrops.map((drop) => ({ ...drop, flight: drop.flight ? { ...drop.flight } : undefined })),
|
||||
rabbits: this.rabbits.map((rabbit) => ({ ...rabbit })),
|
||||
character: { x: this.character.x, y: this.character.y },
|
||||
nodes: this.nodes.map((node) => ({ id: node.id, kind: node.kind, x: node.x, y: node.y, remaining: { ...node.remaining } })),
|
||||
resourceDrops: this.resourceDrops.map((drop) => ({
|
||||
id: drop.id,
|
||||
resource: drop.resource,
|
||||
x: drop.x,
|
||||
y: drop.y,
|
||||
z: drop.z,
|
||||
phase: drop.phase,
|
||||
flight: drop.flight ? { ...drop.flight } : undefined,
|
||||
})),
|
||||
rabbits: this.rabbits.map((rabbit) => ({
|
||||
id: rabbit.id,
|
||||
x: rabbit.x,
|
||||
y: rabbit.y,
|
||||
z: rabbit.z,
|
||||
hp: rabbit.hp,
|
||||
hitFlashTimer: rabbit.hitFlashTimer,
|
||||
healthRegenProgress: rabbit.healthRegenProgress,
|
||||
action: rabbit.action,
|
||||
actionTime: rabbit.actionTime,
|
||||
jumpFromX: rabbit.jumpFromX,
|
||||
jumpFromY: rabbit.jumpFromY,
|
||||
jumpToX: rabbit.jumpToX,
|
||||
jumpToY: rabbit.jumpToY,
|
||||
})),
|
||||
day: this.day,
|
||||
hour: this.hour,
|
||||
characterHp: this.characterHp,
|
||||
characterHp: this.character.hp,
|
||||
});
|
||||
}
|
||||
|
||||
/** Scatters day 1's trees and bushes, keeping every node clear of the character's starting spot,
|
||||
* the map edge, and each other regardless of kind. */
|
||||
private spawnNodes(width: number, height: number): ResourceNode[] {
|
||||
const treePositions = this.spawnPositions(treesForDay(this.day), width, height, []);
|
||||
const bushPositions = this.spawnPositions(bushesForDay(this.day), width, height, treePositions);
|
||||
return [...this.buildTrees(treePositions), ...this.buildBushes(bushPositions)];
|
||||
const treePositions = pickSpawnPositions(treesForDay(this.day), width, height, []);
|
||||
const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions);
|
||||
return [
|
||||
...treePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
|
||||
...bushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)),
|
||||
];
|
||||
}
|
||||
|
||||
/** A new day doesn't clear existing, unharvested plants — it only tops each kind up (at random
|
||||
@@ -365,17 +337,20 @@ export class GameStore {
|
||||
const treeDeficit = Math.max(0, treesForDay(this.day) - this.nodes.filter((n) => n.kind === 'tree').length);
|
||||
const bushDeficit = Math.max(0, bushesForDay(this.day) - this.nodes.filter((n) => n.kind === 'bush').length);
|
||||
|
||||
const newTreePositions = this.spawnPositions(treeDeficit, width, height, existing);
|
||||
const newBushPositions = this.spawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions]);
|
||||
const newNodes = [...this.buildTrees(newTreePositions), ...this.buildBushes(newBushPositions)];
|
||||
const newTreePositions = pickSpawnPositions(treeDeficit, width, height, existing);
|
||||
const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions]);
|
||||
const newNodes = [
|
||||
...newTreePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
|
||||
...newBushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)),
|
||||
];
|
||||
|
||||
if (newNodes.length > 0)
|
||||
this.nodes = [...this.nodes, ...newNodes];
|
||||
}
|
||||
|
||||
private spawnRabbits(width: number, height: number, count: number): RabbitState[] {
|
||||
const positions = this.spawnPositions(count, width, height, []);
|
||||
return positions.map((pos) => this.buildRabbit(pos));
|
||||
private spawnRabbits(width: number, height: number, count: number): Rabbit[] {
|
||||
const positions = pickSpawnPositions(count, width, height, []);
|
||||
return positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp));
|
||||
}
|
||||
|
||||
/** Tops the rabbit population up to this day's cap (never removes any — the cap only ever
|
||||
@@ -389,78 +364,15 @@ export class GameStore {
|
||||
return;
|
||||
|
||||
const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y }));
|
||||
const positions = this.spawnPositions(deficit, width, height, existing);
|
||||
this.rabbits = [...this.rabbits, ...positions.map((pos) => this.buildRabbit(pos))];
|
||||
}
|
||||
|
||||
private buildRabbit(pos: Vec2): RabbitState {
|
||||
return {
|
||||
id: randomId('rabbit'),
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: 0,
|
||||
hp: this.rabbitMaxHp,
|
||||
hitFlashTimer: 0,
|
||||
healthRegenProgress: 0,
|
||||
action: 'idle',
|
||||
// Staggered so freshly spawned rabbits don't all start their first action in lockstep.
|
||||
actionTime: Math.random() * RABBIT_PAUSE_DURATION,
|
||||
jumpFromX: pos.x,
|
||||
jumpFromY: pos.y,
|
||||
jumpToX: pos.x,
|
||||
jumpToY: pos.y,
|
||||
};
|
||||
}
|
||||
|
||||
private buildTrees(positions: Vec2[]): ResourceNode[] {
|
||||
return positions.map((pos) => ({
|
||||
id: randomId('tree'),
|
||||
kind: 'tree',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
remaining: { log: LOGS_PER_TREE, twig: TWIGS_PER_TREE },
|
||||
}));
|
||||
}
|
||||
|
||||
private buildBushes(positions: Vec2[]): ResourceNode[] {
|
||||
return positions.map((pos) => ({
|
||||
id: randomId('bush'),
|
||||
kind: 'bush',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
remaining: { log: 0, twig: TWIGS_PER_BUSH },
|
||||
}));
|
||||
}
|
||||
|
||||
private spawnPositions(count: number, width: number, height: number, existing: Vec2[]): Vec2[] {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const positions: Vec2[] = [];
|
||||
let attempts = 0;
|
||||
|
||||
while (positions.length < count && attempts < 1000) {
|
||||
attempts++;
|
||||
const x = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2);
|
||||
const y = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, height - ENTITY_EDGE_MARGIN * 2);
|
||||
|
||||
if (Math.hypot(x - centerX, y - centerY) < ENTITY_MIN_DISTANCE_FROM_CENTER)
|
||||
continue;
|
||||
if (existing.some((p) => Math.hypot(p.x - x, p.y - y) < ENTITY_MIN_DISTANCE_BETWEEN))
|
||||
continue;
|
||||
if (positions.some((p) => Math.hypot(p.x - x, p.y - y) < ENTITY_MIN_DISTANCE_BETWEEN))
|
||||
continue;
|
||||
|
||||
positions.push({ x, y });
|
||||
}
|
||||
|
||||
return positions;
|
||||
const positions = pickSpawnPositions(deficit, width, height, existing);
|
||||
this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp))];
|
||||
}
|
||||
|
||||
holdNode(id: string) {
|
||||
if (this.nodes.some((node) => node.id === id)) {
|
||||
this.moveTarget = null;
|
||||
this.heldRabbitId = null;
|
||||
this.rabbitAttackProgress = 0;
|
||||
this.character.resetAttackProgress();
|
||||
this.heldNodeId = id;
|
||||
}
|
||||
}
|
||||
@@ -473,7 +385,7 @@ export class GameStore {
|
||||
// (e.g. every pointermove while the cursor stays over it) must not wipe out attack
|
||||
// progress that's already accumulating, or a held rabbit could never take a hit.
|
||||
if (this.heldRabbitId !== id)
|
||||
this.rabbitAttackProgress = 0;
|
||||
this.character.resetAttackProgress();
|
||||
this.heldRabbitId = id;
|
||||
}
|
||||
}
|
||||
@@ -482,8 +394,8 @@ export class GameStore {
|
||||
moveTo(x: number, y: number) {
|
||||
this.heldNodeId = null;
|
||||
this.heldRabbitId = null;
|
||||
this.chopProgress = 0;
|
||||
this.rabbitAttackProgress = 0;
|
||||
this.character.resetChopProgress();
|
||||
this.character.resetAttackProgress();
|
||||
this.moveTarget = { x, y };
|
||||
}
|
||||
|
||||
@@ -491,8 +403,8 @@ export class GameStore {
|
||||
this.heldNodeId = null;
|
||||
this.heldRabbitId = null;
|
||||
this.moveTarget = null;
|
||||
this.chopProgress = 0;
|
||||
this.rabbitAttackProgress = 0;
|
||||
this.character.resetChopProgress();
|
||||
this.character.resetAttackProgress();
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -520,7 +432,11 @@ export class GameStore {
|
||||
this.lastTimestamp = null;
|
||||
}
|
||||
|
||||
private tick(deltaTime: number) {
|
||||
/** Advances the whole game by deltaTime seconds. Public and side-effect-free beyond its own
|
||||
* state (no DOM/timers touched) — a headless script can call this directly in a loop instead of
|
||||
* start()/stop() to run the game at any speed, e.g. to play millions of ticks and measure how
|
||||
* often a player would need to act. */
|
||||
tick(deltaTime: number) {
|
||||
if (this.isDead)
|
||||
return;
|
||||
|
||||
@@ -528,7 +444,7 @@ export class GameStore {
|
||||
this.updateBloodMoon(deltaTime);
|
||||
this.updateMovementAndChopping(deltaTime);
|
||||
this.updateRabbits(deltaTime);
|
||||
this.updateHealthRegen(deltaTime);
|
||||
this.character.regenerateHealth(deltaTime, this.healthRegenInterval, this.characterMaxHp, this.isCharacterThreatened);
|
||||
if (this.characterHitFlashTimer > 0)
|
||||
this.characterHitFlashTimer = Math.max(0, this.characterHitFlashTimer - deltaTime);
|
||||
this.updateDropFlights(deltaTime);
|
||||
@@ -537,6 +453,15 @@ export class GameStore {
|
||||
this.updateAutosave(deltaTime);
|
||||
}
|
||||
|
||||
/** Whether a rabbit is close enough, at night, to strike — pauses the character's regen even
|
||||
* between one rabbit's individual strikes, since regenerating while actively hunted would
|
||||
* undercut the threat. */
|
||||
private get isCharacterThreatened(): boolean {
|
||||
return this.isNight && this.rabbits.some(
|
||||
(rabbit) => Math.hypot(rabbit.x - this.character.x, rabbit.y - this.character.y) <= this.rabbitStrikeRange,
|
||||
);
|
||||
}
|
||||
|
||||
/** Shows the "КРОВАВАЯ ЛУНА!" banner for a few seconds and heals every rabbit to full (their
|
||||
* now-tripled max hp) right as a blood moon night begins. */
|
||||
private updateBloodMoon(deltaTime: number) {
|
||||
@@ -551,43 +476,18 @@ export class GameStore {
|
||||
this.lastAnnouncedBloodMoonDay = nightDay;
|
||||
this.bloodMoonBannerTimer = BLOOD_MOON_BANNER_SECONDS;
|
||||
const maxHp = this.rabbitMaxHp;
|
||||
for (const rabbit of this.rabbits) {
|
||||
rabbit.hp = maxHp;
|
||||
rabbit.healthRegenProgress = 0;
|
||||
}
|
||||
for (const rabbit of this.rabbits)
|
||||
rabbit.healToFull(maxHp);
|
||||
}
|
||||
|
||||
/** Hp regenerates a whole point at a time (same accumulate-then-spend cadence as chopProgress,
|
||||
* coinProgress, ...) while no rabbit is close enough, at night, to strike — regenerating while
|
||||
* actively hunted would undercut the threat, so proximity alone pauses it even between one
|
||||
* rabbit's individual strikes. Actual damage happens elsewhere, in landRabbitStrike, once a
|
||||
* rabbit's own strike action completes. */
|
||||
private updateHealthRegen(deltaTime: number) {
|
||||
const isThreatened = this.isNight && this.rabbits.some(
|
||||
(rabbit) => Math.hypot(rabbit.x - this.character.x, rabbit.y - this.character.y) <= this.rabbitStrikeRange,
|
||||
);
|
||||
if (isThreatened || this.characterHp >= this.characterMaxHp) {
|
||||
this.healthRegenProgress = 0;
|
||||
return;
|
||||
}
|
||||
const interval = this.healthRegenInterval;
|
||||
this.healthRegenProgress += deltaTime;
|
||||
while (this.healthRegenProgress >= interval && this.characterHp < this.characterMaxHp) {
|
||||
this.healthRegenProgress -= interval;
|
||||
// Clamped rather than a plain += 1 — hp can now sit at a fractional value (e.g. after taking
|
||||
// fractional damage), so a whole-point step could otherwise overshoot characterMaxHp.
|
||||
this.characterHp = Math.min(this.characterMaxHp, this.characterHp + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** A rabbit's own damage lands the instant its strike action begins (see
|
||||
* startNextRabbitAction) rather than draining continuously or waiting for the action to play
|
||||
* out — the ~1s of "striking" afterward is just the rabbit committed to that action and unable
|
||||
* to do anything else, not a wind-up the character could still escape. */
|
||||
private landRabbitStrike() {
|
||||
this.characterHp = Math.max(0, this.characterHp - this.rabbitStrikeDamage);
|
||||
/** A rabbit's own damage lands the instant its strike action begins (see Rabbit.doNextAction)
|
||||
* rather than draining continuously or waiting for the action to play out — the ~1s of
|
||||
* "striking" afterward is just the rabbit committed to that action and unable to do anything
|
||||
* else, not a wind-up the character could still escape. */
|
||||
private landRabbitStrike(): void {
|
||||
this.character.takeDamage(this.rabbitStrikeDamage);
|
||||
this.characterHitFlashTimer = HIT_FLASH_DURATION;
|
||||
if (this.characterHp <= 0) {
|
||||
if (this.character.hp <= 0) {
|
||||
this.isDead = true;
|
||||
clearSave();
|
||||
}
|
||||
@@ -607,138 +507,26 @@ export class GameStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Each rabbit independently cycles: pause (~1s, deciding its next action) → jump or strike
|
||||
* (~1s, committed — see updateRabbitAction) → pause → ... A rabbit does nothing but the current
|
||||
* action while it's mid-action; it only reacts to the character's position at the instant each
|
||||
* pause ends. */
|
||||
/** Each rabbit independently cycles: pause (deciding its next action) → jump or strike
|
||||
* (committed — see Rabbit.update) → pause → ... reacting to the character's position only at
|
||||
* the instant each pause ends. */
|
||||
private updateRabbits(deltaTime: number) {
|
||||
if (this.rabbits.length === 0 || !this.mapSize)
|
||||
return;
|
||||
for (const rabbit of this.rabbits) {
|
||||
this.updateRabbitHealthRegen(rabbit, deltaTime);
|
||||
this.updateRabbitAction(rabbit, deltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixed 1 hp / RABBIT_HEALTH_REGEN_INTERVAL seconds, same accumulate-then-spend cadence as the
|
||||
* character's own regen — unlike the character's, this isn't paused by anything (no upgrade for
|
||||
* it either), so it ticks along even mid-fight. */
|
||||
private updateRabbitHealthRegen(rabbit: RabbitState, deltaTime: number) {
|
||||
const maxHp = this.rabbitMaxHp;
|
||||
if (rabbit.hp >= maxHp) {
|
||||
rabbit.healthRegenProgress = 0;
|
||||
return;
|
||||
const ctx: RabbitTickContext = {
|
||||
characterPos: this.character,
|
||||
isNight: this.isNight,
|
||||
strikeRange: this.rabbitStrikeRange,
|
||||
mapWidth: this.mapSize.width,
|
||||
mapHeight: this.mapSize.height,
|
||||
onStrike: () => this.landRabbitStrike(),
|
||||
};
|
||||
for (const rabbit of this.rabbits) {
|
||||
rabbit.regenerateHealth(deltaTime, RABBIT_HEALTH_REGEN_INTERVAL, maxHp);
|
||||
rabbit.update(deltaTime, ctx);
|
||||
}
|
||||
rabbit.healthRegenProgress += deltaTime;
|
||||
while (rabbit.healthRegenProgress >= RABBIT_HEALTH_REGEN_INTERVAL && rabbit.hp < maxHp) {
|
||||
rabbit.healthRegenProgress -= RABBIT_HEALTH_REGEN_INTERVAL;
|
||||
rabbit.hp = Math.min(maxHp, rabbit.hp + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private updateRabbitAction(rabbit: RabbitState, deltaTime: number) {
|
||||
if (rabbit.hitFlashTimer > 0)
|
||||
rabbit.hitFlashTimer = Math.max(0, rabbit.hitFlashTimer - deltaTime);
|
||||
|
||||
rabbit.actionTime += deltaTime;
|
||||
|
||||
if (rabbit.action === 'idle') {
|
||||
if (rabbit.actionTime < RABBIT_PAUSE_DURATION)
|
||||
return;
|
||||
rabbit.actionTime -= RABBIT_PAUSE_DURATION;
|
||||
this.startNextRabbitAction(rabbit);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rabbit.action === 'jumping') {
|
||||
const t = Math.min(1, rabbit.actionTime / RABBIT_ACTION_DURATION);
|
||||
rabbit.x = rabbit.jumpFromX + (rabbit.jumpToX - rabbit.jumpFromX) * t;
|
||||
rabbit.y = rabbit.jumpFromY + (rabbit.jumpToY - rabbit.jumpFromY) * t;
|
||||
// Same parabola as a resource drop's flight: 0 at both ends, peaking at the midpoint.
|
||||
rabbit.z = RABBIT_JUMP_ARC_HEIGHT * 4 * t * (1 - t);
|
||||
if (rabbit.actionTime >= RABBIT_ACTION_DURATION) {
|
||||
rabbit.actionTime -= RABBIT_ACTION_DURATION;
|
||||
rabbit.action = 'idle';
|
||||
rabbit.z = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// striking (damage already landed when the action began — see startNextRabbitAction)
|
||||
if (rabbit.actionTime >= RABBIT_ACTION_DURATION) {
|
||||
rabbit.actionTime -= RABBIT_ACTION_DURATION;
|
||||
rabbit.action = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
/** Chooses what a rabbit does next, right as its pause ends: strike if the character is
|
||||
* already in range at night, otherwise jump (toward the character at night, or a random spot
|
||||
* that respects the minimum distance from them by day). */
|
||||
private startNextRabbitAction(rabbit: RabbitState) {
|
||||
const distance = Math.hypot(this.character.x - rabbit.x, this.character.y - rabbit.y);
|
||||
|
||||
if (this.isNight && distance <= this.rabbitStrikeRange) {
|
||||
rabbit.action = 'striking';
|
||||
this.landRabbitStrike();
|
||||
return;
|
||||
}
|
||||
|
||||
rabbit.action = 'jumping';
|
||||
rabbit.jumpFromX = rabbit.x;
|
||||
rabbit.jumpFromY = rabbit.y;
|
||||
const target = this.pickRabbitJumpTarget(rabbit, distance);
|
||||
rabbit.jumpToX = target.x;
|
||||
rabbit.jumpToY = target.y;
|
||||
}
|
||||
|
||||
private pickRabbitJumpTarget(rabbit: RabbitState, distanceToCharacter: number): Vec2 {
|
||||
const { width, height } = this.mapSize!;
|
||||
let angle: number;
|
||||
let jumpDistance: number;
|
||||
|
||||
if (this.isNight) {
|
||||
// "Roughly toward" the character — a direct beeline with some random deviation — and never
|
||||
// overshooting past them.
|
||||
const towardCharacter = Math.atan2(this.character.y - rabbit.y, this.character.x - rabbit.x);
|
||||
const spread = RABBIT_NIGHT_JUMP_SPREAD_DEGREES * (Math.PI / 180);
|
||||
angle = towardCharacter + (Math.random() * 2 - 1) * spread;
|
||||
jumpDistance = Math.min(RABBIT_JUMP_DISTANCE, distanceToCharacter);
|
||||
} else {
|
||||
angle = this.pickDayJumpAngle(rabbit);
|
||||
jumpDistance = RABBIT_JUMP_DISTANCE;
|
||||
}
|
||||
|
||||
const targetX = Math.min(Math.max(rabbit.x + Math.cos(angle) * jumpDistance, ENTITY_EDGE_MARGIN), width - ENTITY_EDGE_MARGIN);
|
||||
const targetY = Math.min(Math.max(rabbit.y + Math.sin(angle) * jumpDistance, ENTITY_EDGE_MARGIN), height - ENTITY_EDGE_MARGIN);
|
||||
return { x: targetX, y: targetY };
|
||||
}
|
||||
|
||||
/** A random direction whose landing spot keeps at least RABBIT_MIN_DISTANCE_FROM_CHARACTER away
|
||||
* from the character. Jumping directly away from the character is the landing spot that
|
||||
* maximizes distance from them for a fixed jump distance — so if even that can't clear the
|
||||
* minimum (the character is close enough that no direction can), it's used immediately as the
|
||||
* next best thing, rather than wasting attempts on random angles that are all guaranteed to
|
||||
* fail too. Otherwise, a handful of random attempts (typically satisfied well within that many,
|
||||
* since only a narrow cone of angles points back toward the character) picks a natural-looking
|
||||
* direction instead of always fleeing in a perfectly straight line. */
|
||||
private pickDayJumpAngle(rabbit: RabbitState): number {
|
||||
const awayAngle = Math.atan2(rabbit.y - this.character.y, rabbit.x - this.character.x);
|
||||
const maxReachableDistance = Math.hypot(
|
||||
this.character.x - (rabbit.x + Math.cos(awayAngle) * RABBIT_JUMP_DISTANCE),
|
||||
this.character.y - (rabbit.y + Math.sin(awayAngle) * RABBIT_JUMP_DISTANCE),
|
||||
);
|
||||
if (maxReachableDistance < RABBIT_MIN_DISTANCE_FROM_CHARACTER)
|
||||
return awayAngle;
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const targetX = rabbit.x + Math.cos(angle) * RABBIT_JUMP_DISTANCE;
|
||||
const targetY = rabbit.y + Math.sin(angle) * RABBIT_JUMP_DISTANCE;
|
||||
const distanceToCharacter = Math.hypot(this.character.x - targetX, this.character.y - targetY);
|
||||
if (distanceToCharacter >= RABBIT_MIN_DISTANCE_FROM_CHARACTER)
|
||||
return angle;
|
||||
}
|
||||
return awayAngle;
|
||||
}
|
||||
|
||||
private updateAutosave(deltaTime: number) {
|
||||
@@ -763,75 +551,47 @@ export class GameStore {
|
||||
}
|
||||
|
||||
if (this.moveTarget) {
|
||||
this.walkTowardPoint(this.moveTarget, deltaTime);
|
||||
this.character.moveToPosition(this.moveTarget, this.characterSpeed, deltaTime, 0);
|
||||
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,
|
||||
};
|
||||
this.character.resetChopProgress();
|
||||
}
|
||||
|
||||
private walkTowardNode(node: ResourceNode, deltaTime: number) {
|
||||
const dx = node.x - this.character.x;
|
||||
const dy = node.y - this.character.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const distance = Math.hypot(node.x - this.character.x, node.y - this.character.y);
|
||||
|
||||
// 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).
|
||||
// the movement 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;
|
||||
this.character.moveToPosition(node, this.characterSpeed, deltaTime, INTERACTION_RADIUS);
|
||||
this.character.resetChopProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
const chopInterval = this.chopInterval;
|
||||
this.chopProgress += deltaTime;
|
||||
while (this.chopProgress >= chopInterval && !isDepleted(node)) {
|
||||
this.chopProgress -= chopInterval;
|
||||
const resource = nextResource(node);
|
||||
let swings = this.character.accumulateChopSwings(deltaTime, chopInterval);
|
||||
while (swings > 0 && !node.isDepleted) {
|
||||
swings--;
|
||||
const resource = node.chopOnce();
|
||||
if (!resource)
|
||||
break;
|
||||
|
||||
node.remaining[resource] -= 1;
|
||||
this.ejectDrop(node, resource);
|
||||
|
||||
if (isDepleted(node)) {
|
||||
if (node.isDepleted) {
|
||||
this.nodes = this.nodes.filter((n) => n.id !== node.id);
|
||||
this.heldNodeId = null;
|
||||
this.chopProgress = 0;
|
||||
this.character.resetChopProgress();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private walkTowardRabbit(rabbit: RabbitState, deltaTime: number) {
|
||||
const dx = rabbit.x - this.character.x;
|
||||
const dy = rabbit.y - this.character.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
private walkTowardRabbit(rabbit: Rabbit, deltaTime: number) {
|
||||
const distance = Math.hypot(rabbit.x - this.character.x, rabbit.y - this.character.y);
|
||||
const hitRadius = this.rabbitHitRadius;
|
||||
|
||||
// Unlike a stationary node, a rabbit keeps moving after the character reaches hitting
|
||||
@@ -839,17 +599,11 @@ export class GameStore {
|
||||
// the moment it's nominally "close enough." Always closing the remaining gap (rather than
|
||||
// parking at hitRadius the way walkTowardNode does at INTERACTION_RADIUS) keeps the chase
|
||||
// from stalling out just past the boundary every other frame.
|
||||
if (distance > 0.5) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
if (distance > 0.5)
|
||||
this.character.moveToPosition(rabbit, this.characterSpeed, deltaTime, 0);
|
||||
|
||||
if (distance > hitRadius) {
|
||||
this.rabbitAttackProgress = 0;
|
||||
this.character.resetAttackProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -858,10 +612,11 @@ export class GameStore {
|
||||
// (e.g. 3 dmg/hit against a 3-hp rabbit) actually one-shots it, instead of that damage being
|
||||
// smeared out continuously over a whole second.
|
||||
const swingInterval = this.chopInterval;
|
||||
this.rabbitAttackProgress += deltaTime;
|
||||
while (this.rabbitAttackProgress >= swingInterval && rabbit.hp > 0) {
|
||||
this.rabbitAttackProgress -= swingInterval;
|
||||
rabbit.hp -= this.attackDamagePerHit;
|
||||
const damagePerHit = this.attackDamagePerHit;
|
||||
let swings = this.character.accumulateAttackSwings(deltaTime, swingInterval);
|
||||
while (swings > 0 && rabbit.hp > 0) {
|
||||
swings--;
|
||||
rabbit.takeDamage(damagePerHit);
|
||||
rabbit.hitFlashTimer = HIT_FLASH_DURATION;
|
||||
if (rabbit.hp <= 0) {
|
||||
this.killRabbit(rabbit);
|
||||
@@ -870,72 +625,33 @@ export class GameStore {
|
||||
}
|
||||
}
|
||||
|
||||
private killRabbit(rabbit: RabbitState) {
|
||||
private killRabbit(rabbit: Rabbit) {
|
||||
this.rabbits = this.rabbits.filter((r) => r.id !== rabbit.id);
|
||||
if (this.heldRabbitId === rabbit.id) {
|
||||
this.heldRabbitId = null;
|
||||
this.rabbitAttackProgress = 0;
|
||||
this.character.resetAttackProgress();
|
||||
}
|
||||
this.ejectDrop(rabbit, 'meat');
|
||||
}
|
||||
|
||||
// Ejects a resource on a ballistic arc to a random spot within a circle around the origin; it
|
||||
// only becomes pickable once it lands (see updateDropFlights/updatePickups).
|
||||
private ejectDrop(origin: Vec2, resource: ResourceType) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const landDistance = Math.random() * DROP_EJECT_RADIUS;
|
||||
const toX = origin.x + Math.cos(angle) * landDistance;
|
||||
const toY = origin.y + Math.sin(angle) * landDistance;
|
||||
this.resourceDrops.push({
|
||||
id: randomId('drop'),
|
||||
resource,
|
||||
x: origin.x,
|
||||
y: origin.y,
|
||||
z: 0,
|
||||
phase: 'falling',
|
||||
flight: {
|
||||
fromX: origin.x,
|
||||
fromY: origin.y,
|
||||
toX,
|
||||
toY,
|
||||
elapsed: 0,
|
||||
duration: DROP_FALL_DURATION,
|
||||
arcHeight: DROP_FALL_ARC_HEIGHT,
|
||||
},
|
||||
});
|
||||
this.resourceDrops.push(ResourceDrop.eject(origin, resource));
|
||||
}
|
||||
|
||||
/** Advances every resource drop currently in a ballistic flight (falling from a node, or flying
|
||||
* into the inventory once collected), landing/collecting it once its flight completes. Mutates
|
||||
* drops in place and only reassigns the `resourceDrops` array when one is actually removed
|
||||
* (collected) — a landed drop sitting idle, or the array being rebuilt every tick regardless,
|
||||
* would otherwise force every observer of `resourceDrops` (i.e. the whole map) to re-render
|
||||
* every animation frame. */
|
||||
* into the inventory once collected), landing/collecting it once its flight completes. Only
|
||||
* reassigns the `resourceDrops` array when one is actually removed (collected) — a landed drop
|
||||
* sitting idle, or the array being rebuilt every tick regardless, would otherwise force every
|
||||
* observer of `resourceDrops` (i.e. the whole map) to re-render every animation frame. */
|
||||
private updateDropFlights(deltaTime: number) {
|
||||
if (this.resourceDrops.length === 0)
|
||||
return;
|
||||
|
||||
let collectedIds: Set<string> | null = null;
|
||||
for (const drop of this.resourceDrops) {
|
||||
if (drop.phase === 'landed')
|
||||
if (drop.updateFlight(deltaTime) !== 'collected')
|
||||
continue;
|
||||
|
||||
const flight = drop.flight!;
|
||||
flight.elapsed += deltaTime;
|
||||
const t = Math.min(1, flight.elapsed / flight.duration);
|
||||
drop.x = flight.fromX + (flight.toX - flight.fromX) * t;
|
||||
drop.y = flight.fromY + (flight.toY - flight.fromY) * t;
|
||||
drop.z = flight.arcHeight * 4 * t * (1 - t);
|
||||
|
||||
if (t < 1)
|
||||
continue;
|
||||
|
||||
if (drop.phase === 'falling') {
|
||||
drop.phase = 'landed';
|
||||
drop.flight = undefined;
|
||||
drop.z = 0;
|
||||
} else {
|
||||
// Collecting flight finished: the resource has reached the character.
|
||||
(collectedIds ??= new Set()).add(drop.id);
|
||||
if (drop.resource === 'log')
|
||||
this.inventory.logs += 1;
|
||||
@@ -944,7 +660,6 @@ export class GameStore {
|
||||
else
|
||||
this.inventory.meat += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (collectedIds)
|
||||
this.resourceDrops = this.resourceDrops.filter((drop) => !collectedIds!.has(drop.id));
|
||||
@@ -961,16 +676,7 @@ export class GameStore {
|
||||
if (distance > pickupRadius)
|
||||
continue;
|
||||
|
||||
drop.phase = 'collecting';
|
||||
drop.flight = {
|
||||
fromX: drop.x,
|
||||
fromY: drop.y,
|
||||
toX: this.character.x,
|
||||
toY: this.character.y,
|
||||
elapsed: 0,
|
||||
duration: DROP_COLLECT_DURATION,
|
||||
arcHeight: DROP_COLLECT_ARC_HEIGHT,
|
||||
};
|
||||
drop.startCollecting(this.character);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import type { Vec2 } from '../types';
|
||||
|
||||
/** The player-controlled entity: position, hp, and the swing-cadence accumulators for whichever
|
||||
* single action (chopping a node, attacking a rabbit) it's currently engaged in. Framework-free —
|
||||
* no React/DOM/MobX-reaction dependency beyond MobX itself, which runs fine headless — so a
|
||||
* script can drive a Character/GameStore through millions of ticks with no browser involved. */
|
||||
export class Character {
|
||||
x: number;
|
||||
y: number;
|
||||
hp: number;
|
||||
|
||||
private chopProgress = 0;
|
||||
private attackProgress = 0;
|
||||
private regenProgress = 0;
|
||||
|
||||
constructor(x: number, y: number, hp: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.hp = hp;
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
/** Moves up to speed*deltaTime toward target, never crossing closer than keepDistance (0 = walk
|
||||
* all the way to it, landing exactly on it once within reach). Returns the resulting distance
|
||||
* to target, so the caller can decide what to do once within some threshold of it. */
|
||||
moveToPosition(target: Vec2, speed: number, deltaTime: number, keepDistance = 0): number {
|
||||
const dx = target.x - this.x;
|
||||
const dy = target.y - this.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const travelable = Math.max(0, distance - keepDistance);
|
||||
if (travelable <= 0)
|
||||
return distance;
|
||||
|
||||
const step = Math.min(speed * deltaTime, travelable);
|
||||
const ratio = step / distance;
|
||||
this.x += dx * ratio;
|
||||
this.y += dy * ratio;
|
||||
return distance - step;
|
||||
}
|
||||
|
||||
takeDamage(amount: number): void {
|
||||
this.hp = Math.max(0, this.hp - amount);
|
||||
}
|
||||
|
||||
/** Hp regenerates a whole point at a time (accumulate-then-spend), clamped to maxHp so a
|
||||
* fractional starting hp can't be overshot by the whole-point step. Resets progress instead of
|
||||
* accumulating while `paused` (e.g. a rabbit is close enough, at night, to strike). */
|
||||
regenerateHealth(deltaTime: number, interval: number, maxHp: number, paused: boolean): void {
|
||||
if (paused || this.hp >= maxHp) {
|
||||
this.regenProgress = 0;
|
||||
return;
|
||||
}
|
||||
this.regenProgress += deltaTime;
|
||||
while (this.regenProgress >= interval && this.hp < maxHp) {
|
||||
this.regenProgress -= interval;
|
||||
this.hp = Math.min(maxHp, this.hp + 1);
|
||||
}
|
||||
}
|
||||
|
||||
resetChopProgress(): void {
|
||||
this.chopProgress = 0;
|
||||
}
|
||||
|
||||
resetAttackProgress(): void {
|
||||
this.attackProgress = 0;
|
||||
}
|
||||
|
||||
/** Accumulates deltaTime and returns how many chop-swings have completed this call (usually 0 or
|
||||
* 1), consuming that much progress — the discrete swing-cadence pattern shared with attacking. */
|
||||
accumulateChopSwings(deltaTime: number, interval: number): number {
|
||||
this.chopProgress += deltaTime;
|
||||
let swings = 0;
|
||||
while (this.chopProgress >= interval) {
|
||||
this.chopProgress -= interval;
|
||||
swings++;
|
||||
}
|
||||
return swings;
|
||||
}
|
||||
|
||||
/** Same accumulate-then-spend swing cadence as accumulateChopSwings, for attacking a rabbit. */
|
||||
accumulateAttackSwings(deltaTime: number, interval: number): number {
|
||||
this.attackProgress += deltaTime;
|
||||
let swings = 0;
|
||||
while (this.attackProgress >= interval) {
|
||||
this.attackProgress -= interval;
|
||||
swings++;
|
||||
}
|
||||
return swings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import { ENTITY_EDGE_MARGIN } from '../constants';
|
||||
import { randomId } from '../id';
|
||||
import {
|
||||
RABBIT_ACTION_DURATION,
|
||||
RABBIT_JUMP_ARC_HEIGHT,
|
||||
RABBIT_JUMP_DISTANCE,
|
||||
RABBIT_MIN_DISTANCE_FROM_CHARACTER,
|
||||
RABBIT_NIGHT_JUMP_SPREAD_DEGREES,
|
||||
RABBIT_PAUSE_DURATION,
|
||||
} from '../rabbits';
|
||||
import type { RabbitAction, RabbitData, Vec2 } from '../types';
|
||||
|
||||
/** Everything a rabbit needs to decide/animate its next action, supplied fresh each tick by
|
||||
* whatever owns the character and the map — keeps Rabbit decoupled from Character/GameStore. */
|
||||
export interface RabbitTickContext {
|
||||
characterPos: Vec2;
|
||||
isNight: boolean;
|
||||
/** How close the character must be, by night, for a strike instead of a jump (blood-moon-scaled
|
||||
* upstream). */
|
||||
strikeRange: number;
|
||||
mapWidth: number;
|
||||
mapHeight: number;
|
||||
/** Called the instant a strike begins — damage lands at the *start* of the action, not once it
|
||||
* finishes playing out. */
|
||||
onStrike: () => void;
|
||||
}
|
||||
|
||||
/** A rabbit: hops in random directions by day (never too close to the character), and hunts them
|
||||
* at night. Cycles pause → jump-or-strike → pause, one action at a time. Framework-free — no
|
||||
* React/DOM dependency. */
|
||||
export class Rabbit {
|
||||
readonly id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Visual height above ground for the jump's parabolic arc. 0 except mid-jump. */
|
||||
z: number;
|
||||
hp: number;
|
||||
/** Seconds left to play the hit-shake animation — set only the instant a hit actually lands. */
|
||||
hitFlashTimer: number;
|
||||
/** Accumulates real seconds toward the rabbit's next point of regenerated hp. */
|
||||
healthRegenProgress: number;
|
||||
action: RabbitAction;
|
||||
/** Seconds elapsed within the current action (or pause, while 'idle'). */
|
||||
actionTime: number;
|
||||
/** Jump start/end; only meaningful while action === 'jumping'. */
|
||||
jumpFromX: number;
|
||||
jumpFromY: number;
|
||||
jumpToX: number;
|
||||
jumpToY: number;
|
||||
|
||||
constructor(
|
||||
x: number,
|
||||
y: number,
|
||||
hp: number,
|
||||
action: RabbitAction = 'idle',
|
||||
actionTime = 0,
|
||||
jumpFromX = x,
|
||||
jumpFromY = y,
|
||||
jumpToX = x,
|
||||
jumpToY = y,
|
||||
z = 0,
|
||||
hitFlashTimer = 0,
|
||||
healthRegenProgress = 0,
|
||||
id = randomId('rabbit'),
|
||||
) {
|
||||
this.id = id;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
this.hp = hp;
|
||||
this.hitFlashTimer = hitFlashTimer;
|
||||
this.healthRegenProgress = healthRegenProgress;
|
||||
this.action = action;
|
||||
this.actionTime = actionTime;
|
||||
this.jumpFromX = jumpFromX;
|
||||
this.jumpFromY = jumpFromY;
|
||||
this.jumpToX = jumpToX;
|
||||
this.jumpToY = jumpToY;
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
/** Builds a fresh rabbit at `pos`, full hp, staggered mid-pause so freshly spawned rabbits don't
|
||||
* all start their first action in lockstep. */
|
||||
static spawnNew(pos: Vec2, maxHp: number): Rabbit {
|
||||
return new Rabbit(pos.x, pos.y, maxHp, 'idle', Math.random() * RABBIT_PAUSE_DURATION);
|
||||
}
|
||||
|
||||
/** Reconstructs a rabbit from saved data. Every field gets an explicit fallback since a save
|
||||
* from before the current rabbit shape (combat hp, or the jump/strike action cycle) won't have
|
||||
* all of these; z/hitFlashTimer/healthRegenProgress are purely transient and always start fresh
|
||||
* regardless of the save. */
|
||||
static restore(data: Partial<RabbitData> & Pick<RabbitData, 'id' | 'x' | 'y'>, fallbackMaxHp: number): Rabbit {
|
||||
return new Rabbit(
|
||||
data.x,
|
||||
data.y,
|
||||
data.hp ?? fallbackMaxHp,
|
||||
data.action ?? 'idle',
|
||||
data.actionTime ?? 0,
|
||||
data.jumpFromX ?? data.x,
|
||||
data.jumpFromY ?? data.y,
|
||||
data.jumpToX ?? data.x,
|
||||
data.jumpToY ?? data.y,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
data.id,
|
||||
);
|
||||
}
|
||||
|
||||
/** The nearest rabbit to `pos`, or null if there are none. */
|
||||
static findNearestToPosition(rabbits: Rabbit[], pos: Vec2): Rabbit | null {
|
||||
let nearest: Rabbit | null = null;
|
||||
let nearestDistance = Infinity;
|
||||
for (const rabbit of rabbits) {
|
||||
const distance = Math.hypot(rabbit.x - pos.x, rabbit.y - pos.y);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearest = rabbit;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
takeDamage(amount: number): void {
|
||||
this.hp = Math.max(0, this.hp - amount);
|
||||
}
|
||||
|
||||
healToFull(maxHp: number): void {
|
||||
this.hp = maxHp;
|
||||
this.healthRegenProgress = 0;
|
||||
}
|
||||
|
||||
/** Fixed accumulate-then-spend regen, same shape as the character's own — unlike the
|
||||
* character's, this isn't paused by anything, so it ticks along even mid-fight. */
|
||||
regenerateHealth(deltaTime: number, interval: number, maxHp: number): void {
|
||||
if (this.hp >= maxHp) {
|
||||
this.healthRegenProgress = 0;
|
||||
return;
|
||||
}
|
||||
this.healthRegenProgress += deltaTime;
|
||||
while (this.healthRegenProgress >= interval && this.hp < maxHp) {
|
||||
this.healthRegenProgress -= interval;
|
||||
this.hp = Math.min(maxHp, this.hp + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Advances hit-flash decay and the pause/jump/strike action cycle by one tick, reacting to the
|
||||
* character's current position only at the instant a pause ends (see doNextAction). */
|
||||
update(deltaTime: number, ctx: RabbitTickContext): void {
|
||||
if (this.hitFlashTimer > 0)
|
||||
this.hitFlashTimer = Math.max(0, this.hitFlashTimer - deltaTime);
|
||||
|
||||
this.actionTime += deltaTime;
|
||||
|
||||
if (this.action === 'idle') {
|
||||
if (this.actionTime < RABBIT_PAUSE_DURATION)
|
||||
return;
|
||||
this.actionTime -= RABBIT_PAUSE_DURATION;
|
||||
this.doNextAction(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.action === 'jumping') {
|
||||
const t = Math.min(1, this.actionTime / RABBIT_ACTION_DURATION);
|
||||
this.x = this.jumpFromX + (this.jumpToX - this.jumpFromX) * t;
|
||||
this.y = this.jumpFromY + (this.jumpToY - this.jumpFromY) * t;
|
||||
// Same parabola as a resource drop's flight: 0 at both ends, peaking at the midpoint.
|
||||
this.z = RABBIT_JUMP_ARC_HEIGHT * 4 * t * (1 - t);
|
||||
if (this.actionTime >= RABBIT_ACTION_DURATION) {
|
||||
this.actionTime -= RABBIT_ACTION_DURATION;
|
||||
this.action = 'idle';
|
||||
this.z = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// striking (damage already landed when the action began — see doNextAction)
|
||||
if (this.actionTime >= RABBIT_ACTION_DURATION) {
|
||||
this.actionTime -= RABBIT_ACTION_DURATION;
|
||||
this.action = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
/** Chooses what to do next, right as a pause ends: strike if the character is already in range
|
||||
* at night, otherwise jump (toward the character at night, or a random spot that respects the
|
||||
* minimum distance from them by day). */
|
||||
doNextAction(ctx: RabbitTickContext): void {
|
||||
const distance = Math.hypot(ctx.characterPos.x - this.x, ctx.characterPos.y - this.y);
|
||||
|
||||
if (ctx.isNight && distance <= ctx.strikeRange) {
|
||||
this.action = 'striking';
|
||||
ctx.onStrike();
|
||||
return;
|
||||
}
|
||||
|
||||
this.action = 'jumping';
|
||||
this.jumpFromX = this.x;
|
||||
this.jumpFromY = this.y;
|
||||
const target = this.pickJumpTarget(ctx, distance);
|
||||
this.jumpToX = target.x;
|
||||
this.jumpToY = target.y;
|
||||
}
|
||||
|
||||
private pickJumpTarget(ctx: RabbitTickContext, distanceToCharacter: number): Vec2 {
|
||||
let angle: number;
|
||||
let jumpDistance: number;
|
||||
|
||||
if (ctx.isNight) {
|
||||
// "Roughly toward" the character — a direct beeline with some random deviation — and never
|
||||
// overshooting past them.
|
||||
const towardCharacter = Math.atan2(ctx.characterPos.y - this.y, ctx.characterPos.x - this.x);
|
||||
const spread = RABBIT_NIGHT_JUMP_SPREAD_DEGREES * (Math.PI / 180);
|
||||
angle = towardCharacter + (Math.random() * 2 - 1) * spread;
|
||||
jumpDistance = Math.min(RABBIT_JUMP_DISTANCE, distanceToCharacter);
|
||||
} else {
|
||||
angle = this.pickDayJumpAngle(ctx);
|
||||
jumpDistance = RABBIT_JUMP_DISTANCE;
|
||||
}
|
||||
|
||||
const targetX = Math.min(Math.max(this.x + Math.cos(angle) * jumpDistance, ENTITY_EDGE_MARGIN), ctx.mapWidth - ENTITY_EDGE_MARGIN);
|
||||
const targetY = Math.min(Math.max(this.y + Math.sin(angle) * jumpDistance, ENTITY_EDGE_MARGIN), ctx.mapHeight - ENTITY_EDGE_MARGIN);
|
||||
return { x: targetX, y: targetY };
|
||||
}
|
||||
|
||||
/** A random direction whose landing spot keeps at least RABBIT_MIN_DISTANCE_FROM_CHARACTER away
|
||||
* from the character. Jumping directly away from the character is the landing spot that
|
||||
* maximizes distance from them for a fixed jump distance — so if even that can't clear the
|
||||
* minimum (the character is close enough that no direction can), it's used immediately as the
|
||||
* next best thing, rather than wasting attempts on random angles that are all guaranteed to fail
|
||||
* too. Otherwise, a handful of random attempts (typically satisfied well within that many, since
|
||||
* only a narrow cone of angles points back toward the character) picks a natural-looking
|
||||
* direction instead of always fleeing in a perfectly straight line. */
|
||||
private pickDayJumpAngle(ctx: RabbitTickContext): number {
|
||||
const awayAngle = Math.atan2(this.y - ctx.characterPos.y, this.x - ctx.characterPos.x);
|
||||
const maxReachableDistance = Math.hypot(
|
||||
ctx.characterPos.x - (this.x + Math.cos(awayAngle) * RABBIT_JUMP_DISTANCE),
|
||||
ctx.characterPos.y - (this.y + Math.sin(awayAngle) * RABBIT_JUMP_DISTANCE),
|
||||
);
|
||||
if (maxReachableDistance < RABBIT_MIN_DISTANCE_FROM_CHARACTER)
|
||||
return awayAngle;
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const targetX = this.x + Math.cos(angle) * RABBIT_JUMP_DISTANCE;
|
||||
const targetY = this.y + Math.sin(angle) * RABBIT_JUMP_DISTANCE;
|
||||
const distanceToCharacter = Math.hypot(ctx.characterPos.x - targetX, ctx.characterPos.y - targetY);
|
||||
if (distanceToCharacter >= RABBIT_MIN_DISTANCE_FROM_CHARACTER)
|
||||
return angle;
|
||||
}
|
||||
return awayAngle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import {
|
||||
DROP_COLLECT_ARC_HEIGHT,
|
||||
DROP_COLLECT_DURATION,
|
||||
DROP_EJECT_RADIUS,
|
||||
DROP_FALL_ARC_HEIGHT,
|
||||
DROP_FALL_DURATION,
|
||||
} from '../constants';
|
||||
import { randomId } from '../id';
|
||||
import type { DropPhase, Flight, ResourceDropData, ResourceType, Vec2 } from '../types';
|
||||
|
||||
/** A resource in flight — ejected from a chopped node or a killed rabbit, falling to a landing
|
||||
* spot, then (once the character gets close) flying into the inventory. Framework-free — no
|
||||
* React/DOM dependency. */
|
||||
export class ResourceDrop {
|
||||
readonly id: string;
|
||||
resource: ResourceType;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Visual height above ground during a flight, for the ballistic arc. 0 once landed. */
|
||||
z: number;
|
||||
phase: DropPhase;
|
||||
flight?: Flight;
|
||||
|
||||
constructor(resource: ResourceType, x: number, y: number, z: number, phase: DropPhase, flight: Flight | undefined, id = randomId('drop')) {
|
||||
this.id = id;
|
||||
this.resource = resource;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
this.phase = phase;
|
||||
this.flight = flight;
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
/** Ejects a resource from `origin` on a ballistic arc to a random spot within a circle around
|
||||
* it; it only becomes pickable once it lands (see updateFlight). */
|
||||
static eject(origin: Vec2, resource: ResourceType): ResourceDrop {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const landDistance = Math.random() * DROP_EJECT_RADIUS;
|
||||
const toX = origin.x + Math.cos(angle) * landDistance;
|
||||
const toY = origin.y + Math.sin(angle) * landDistance;
|
||||
return new ResourceDrop(resource, origin.x, origin.y, 0, 'falling', {
|
||||
fromX: origin.x,
|
||||
fromY: origin.y,
|
||||
toX,
|
||||
toY,
|
||||
elapsed: 0,
|
||||
duration: DROP_FALL_DURATION,
|
||||
arcHeight: DROP_FALL_ARC_HEIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reconstructs a drop from saved data (a straight deep copy). */
|
||||
static restore(data: ResourceDropData): ResourceDrop {
|
||||
return new ResourceDrop(data.resource, data.x, data.y, data.z, data.phase, data.flight ? { ...data.flight } : undefined, data.id);
|
||||
}
|
||||
|
||||
/** Starts a landed drop flying into the inventory once the character gets close enough. */
|
||||
startCollecting(target: Vec2): void {
|
||||
this.phase = 'collecting';
|
||||
this.flight = {
|
||||
fromX: this.x,
|
||||
fromY: this.y,
|
||||
toX: target.x,
|
||||
toY: target.y,
|
||||
elapsed: 0,
|
||||
duration: DROP_COLLECT_DURATION,
|
||||
arcHeight: DROP_COLLECT_ARC_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
/** Advances an in-progress flight along its ballistic arc. Returns 'flying' while still en
|
||||
* route, 'landed' the instant a falling flight completes (or if there's no flight at all), or
|
||||
* 'collected' the instant a collecting flight reaches the character — the caller applies the
|
||||
* inventory gain and removes the drop only on 'collected'. */
|
||||
updateFlight(deltaTime: number): 'flying' | 'landed' | 'collected' {
|
||||
if (this.phase === 'landed' || !this.flight)
|
||||
return 'landed';
|
||||
|
||||
const flight = this.flight;
|
||||
flight.elapsed += deltaTime;
|
||||
const t = Math.min(1, flight.elapsed / flight.duration);
|
||||
this.x = flight.fromX + (flight.toX - flight.fromX) * t;
|
||||
this.y = flight.fromY + (flight.toY - flight.fromY) * t;
|
||||
this.z = flight.arcHeight * 4 * t * (1 - t);
|
||||
|
||||
if (t < 1)
|
||||
return 'flying';
|
||||
|
||||
if (this.phase === 'falling') {
|
||||
this.phase = 'landed';
|
||||
this.flight = undefined;
|
||||
this.z = 0;
|
||||
return 'landed';
|
||||
}
|
||||
|
||||
return 'collected';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import { LOGS_PER_TREE, TWIGS_PER_BUSH, TWIGS_PER_TREE } from '../constants';
|
||||
import { randomId } from '../id';
|
||||
import type { NodeKind, NodeResourceType, ResourceNodeData, Vec2 } from '../types';
|
||||
|
||||
/** A node yields its logs before its twigs. */
|
||||
const RESOURCE_ORDER: NodeResourceType[] = ['log', 'twig'];
|
||||
|
||||
/** A choppable tree or bush. Framework-free — no React/DOM dependency. */
|
||||
export class ResourceNode {
|
||||
readonly id: string;
|
||||
readonly kind: NodeKind;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Remaining yield per resource type; the node disappears once every count reaches 0. */
|
||||
remaining: Record<NodeResourceType, number>;
|
||||
|
||||
constructor(kind: NodeKind, x: number, y: number, remaining: Record<NodeResourceType, number>, id = randomId(kind)) {
|
||||
this.id = id;
|
||||
this.kind = kind;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.remaining = remaining;
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
/** Builds a fresh tree or bush at `pos`, with its kind's full starting yield. */
|
||||
static spawnNew(kind: NodeKind, pos: Vec2): ResourceNode {
|
||||
const remaining = kind === 'tree'
|
||||
? { log: LOGS_PER_TREE, twig: TWIGS_PER_TREE }
|
||||
: { log: 0, twig: TWIGS_PER_BUSH };
|
||||
return new ResourceNode(kind, pos.x, pos.y, remaining);
|
||||
}
|
||||
|
||||
/** Reconstructs a node from saved data (a straight deep copy — every field has always been
|
||||
* required, so no defensive fallback is needed here). */
|
||||
static restore(data: ResourceNodeData): ResourceNode {
|
||||
return new ResourceNode(data.kind, data.x, data.y, { ...data.remaining }, data.id);
|
||||
}
|
||||
|
||||
/** The nearest node to `pos`, optionally restricted to one kind, or null if there are none. */
|
||||
static findNearestToPosition(nodes: ResourceNode[], pos: Vec2, kind?: NodeKind): ResourceNode | null {
|
||||
let nearest: ResourceNode | null = null;
|
||||
let nearestDistance = Infinity;
|
||||
for (const node of nodes) {
|
||||
if (kind && node.kind !== kind)
|
||||
continue;
|
||||
const distance = Math.hypot(node.x - pos.x, node.y - pos.y);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearest = node;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
get isDepleted(): boolean {
|
||||
return RESOURCE_ORDER.every((resource) => this.remaining[resource] <= 0);
|
||||
}
|
||||
|
||||
/** The next resource this node should yield when chopped, or null once fully depleted. */
|
||||
get nextResource(): NodeResourceType | null {
|
||||
return RESOURCE_ORDER.find((resource) => this.remaining[resource] > 0) ?? null;
|
||||
}
|
||||
|
||||
/** Removes one unit of its next resource. Returns which resource was taken, or null if already
|
||||
* depleted. */
|
||||
chopOnce(): NodeResourceType | null {
|
||||
const resource = this.nextResource;
|
||||
if (!resource)
|
||||
return null;
|
||||
this.remaining[resource] -= 1;
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** A short random id, prefixed by entity kind for readability in save data / debugging. */
|
||||
export function randomId(prefix: string): string {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Inventory, RabbitState, ResourceDrop, ResourceNode, Vec2 } from './types';
|
||||
import type { Inventory, RabbitData, ResourceDropData, ResourceNodeData, Vec2 } from './types';
|
||||
import type { UpgradeId } from './upgrades';
|
||||
|
||||
const STORAGE_KEY = 'forest-save-v1';
|
||||
@@ -10,10 +10,10 @@ export interface SaveData {
|
||||
/** World state (character/nodes/drops) is optional so older saves without it still load fine —
|
||||
* progress is restored either way, just with a freshly spawned world. */
|
||||
character?: Vec2;
|
||||
nodes?: ResourceNode[];
|
||||
resourceDrops?: ResourceDrop[];
|
||||
nodes?: ResourceNodeData[];
|
||||
resourceDrops?: ResourceDropData[];
|
||||
/** Absent on saves from before rabbits existed, which get a freshly spawned batch instead. */
|
||||
rabbits?: RabbitState[];
|
||||
rabbits?: RabbitData[];
|
||||
/** Absent on older saves, which are treated as day 1. */
|
||||
day?: number;
|
||||
/** In-game clock hour (0-24). Absent on older saves, which are treated as noon. */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ENTITY_EDGE_MARGIN, ENTITY_MIN_DISTANCE_BETWEEN, ENTITY_MIN_DISTANCE_FROM_CENTER } from './constants';
|
||||
import type { Vec2 } from './types';
|
||||
|
||||
/** Scatters up to `count` new positions across a `width` x `height` map, keeping clear of the map
|
||||
* center (the character's spawn point), the map edges, and every existing/already-picked position
|
||||
* — shared by nodes and rabbits alike, which all use the same spacing rules. */
|
||||
export function pickSpawnPositions(count: number, width: number, height: number, existing: Vec2[]): Vec2[] {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const positions: Vec2[] = [];
|
||||
let attempts = 0;
|
||||
|
||||
while (positions.length < count && attempts < 1000) {
|
||||
attempts++;
|
||||
const x = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2);
|
||||
const y = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, height - ENTITY_EDGE_MARGIN * 2);
|
||||
|
||||
if (Math.hypot(x - centerX, y - centerY) < ENTITY_MIN_DISTANCE_FROM_CENTER)
|
||||
continue;
|
||||
if (existing.some((p) => Math.hypot(p.x - x, p.y - y) < ENTITY_MIN_DISTANCE_BETWEEN))
|
||||
continue;
|
||||
if (positions.some((p) => Math.hypot(p.x - x, p.y - y) < ENTITY_MIN_DISTANCE_BETWEEN))
|
||||
continue;
|
||||
|
||||
positions.push({ x, y });
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
+8
-4
@@ -8,7 +8,9 @@ export type ResourceType = 'log' | 'twig' | 'meat';
|
||||
export type NodeResourceType = 'log' | 'twig';
|
||||
export type NodeKind = 'tree' | 'bush';
|
||||
|
||||
export interface ResourceNode {
|
||||
/** Plain save-data shape for a ResourceNode (see entities/ResourceNode.ts) — the runtime entity
|
||||
* class satisfies this shape structurally, so `{ ...node }` already serializes to it. */
|
||||
export interface ResourceNodeData {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
x: number;
|
||||
@@ -29,7 +31,8 @@ export interface Flight {
|
||||
arcHeight: number;
|
||||
}
|
||||
|
||||
export interface ResourceDrop {
|
||||
/** Plain save-data shape for a ResourceDrop (see entities/ResourceDrop.ts). */
|
||||
export interface ResourceDropData {
|
||||
id: string;
|
||||
resource: ResourceType;
|
||||
x: number;
|
||||
@@ -53,11 +56,12 @@ export interface Inventory {
|
||||
* else — including react to the character moving — until the current action finishes. */
|
||||
export type RabbitAction = 'idle' | 'jumping' | 'striking';
|
||||
|
||||
export interface RabbitState {
|
||||
/** Plain save-data shape for a Rabbit (see entities/Rabbit.ts). */
|
||||
export interface RabbitData {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Visual height above ground for the jump's parabolic arc (same idea as ResourceDrop.z). 0
|
||||
/** Visual height above ground for the jump's parabolic arc (same idea as ResourceDropData.z). 0
|
||||
* except mid-jump. */
|
||||
z: number;
|
||||
/** Hits remaining before it dies and drops meat. */
|
||||
|
||||
@@ -88,7 +88,7 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
||||
// Game-hours per real second during morning/day/evening (16 game-hours by default spread
|
||||
// over the default 10s day-phase of the 20s day-night cycle) — night has its own fixed speed.
|
||||
effectAtLevel: (level) => 16 * Math.pow(1.15, level - 1),
|
||||
formatEffect: (v, level) => `${v.toFixed(0)} с`,
|
||||
formatEffect: (v) => `${v.toFixed(0)} с`,
|
||||
},
|
||||
{
|
||||
id: 'maxHealth',
|
||||
|
||||
Reference in New Issue
Block a user