Compare commits
6
Commits
396a604671
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd9b5d9aec | ||
|
|
f07602b533 | ||
|
|
0c374e3ef8 | ||
|
|
10041d9593 | ||
|
|
2f57140899 | ||
|
|
dbc0094986 |
Generated
+504
@@ -20,6 +20,7 @@
|
|||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
"oxlint": "^1.79.0",
|
"oxlint": "^1.79.0",
|
||||||
|
"tsx": "^4.23.13",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2"
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,448 @@
|
|||||||
"typescript": "^5.6.3"
|
"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": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.148.0",
|
"version": "0.148.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
|
||||||
@@ -714,6 +1157,48 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1237,6 +1722,25 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
|||||||
+4
-1
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"sim": "tsx scripts/simulate.ts",
|
||||||
|
"play": "tsx scripts/play.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"break_eternity.js": "^2.1.3",
|
"break_eternity.js": "^2.1.3",
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
"oxlint": "^1.79.0",
|
"oxlint": "^1.79.0",
|
||||||
|
"tsx": "^4.23.13",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
/** Headless test bot: plays the game continuously and prints every action it takes, timestamped
|
||||||
|
* with the in-game clock. Runs until interrupted with Ctrl+C.
|
||||||
|
*
|
||||||
|
* Policy: at night, flee the nearest rabbit (survival comes first, regardless of what else is
|
||||||
|
* available). By day, chop the nearest node (tree or bush, whichever is closer) if one exists;
|
||||||
|
* once none are left, hunt the nearest rabbit instead. Whenever coins can afford it, buy the
|
||||||
|
* cheapest currently-affordable upgrade (repeating until nothing more is affordable), printing
|
||||||
|
* every upgrade's level after each purchase.
|
||||||
|
*
|
||||||
|
* Ticks are not paced to real time — each tick always advances the simulated clock by a fixed
|
||||||
|
* TICK_SECONDS, and ticks run back-to-back as fast as the CPU allows (yielding briefly between
|
||||||
|
* batches only so Ctrl+C and console output stay responsive).
|
||||||
|
*
|
||||||
|
* Controls (when stdin is a terminal): Space pauses/resumes, Escape (or Ctrl+C) stops.
|
||||||
|
*
|
||||||
|
* Run with: npx tsx scripts/play.ts [tickSeconds]
|
||||||
|
*/
|
||||||
|
import readline from 'node:readline';
|
||||||
|
import { bushesForDay, treesForDay } from '../src/game/constants';
|
||||||
|
import { formatClock } from '../src/game/daynight';
|
||||||
|
import { Rabbit } from '../src/game/entities/Rabbit';
|
||||||
|
import { ResourceNode } from '../src/game/entities/ResourceNode';
|
||||||
|
import { GameStore } from '../src/game/GameStore';
|
||||||
|
import { costForNextLevel, UPGRADE_LIST, type UpgradeId } from '../src/game/upgrades';
|
||||||
|
|
||||||
|
const MAP_WIDTH = 1200;
|
||||||
|
const MAP_HEIGHT = 800;
|
||||||
|
/** How many simulated seconds pass per tick — fixed, independent of how fast ticks actually run. */
|
||||||
|
const TICK_SECONDS = Number(process.argv[2] ?? 0.1);
|
||||||
|
/** Ticks per event-loop turn between yields — batching amortizes the yield's overhead while
|
||||||
|
* still keeping Ctrl+C/output responsive within about one batch's worth of latency. */
|
||||||
|
const TICKS_PER_BATCH = 1000;
|
||||||
|
/** How far past the nearest rabbit to aim while fleeing it. */
|
||||||
|
const FLEE_DISTANCE = 400;
|
||||||
|
|
||||||
|
const store = new GameStore();
|
||||||
|
store.initialize(MAP_WIDTH, MAP_HEIGHT);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
/** Total simulated seconds ticked so far — a monotonic counter independent of the in-game
|
||||||
|
* day/hour clock (which resets on death and runs at a variable, upgradable pace). */
|
||||||
|
let totalSimulatedSeconds = 0;
|
||||||
|
let lastPurchaseSimulatedSeconds = 0;
|
||||||
|
|
||||||
|
/** Real time spent paused so far, excluded from formatElapsed() so the displayed elapsed real
|
||||||
|
* time freezes while paused instead of continuing to tick. */
|
||||||
|
let pausedAccumulatedMs = 0;
|
||||||
|
let pauseStartedAt: number | null = null;
|
||||||
|
|
||||||
|
/** A duration (seconds) as HH:MM:SS. */
|
||||||
|
function formatDuration(totalSecondsRaw: number): string {
|
||||||
|
const totalSeconds = Math.floor(totalSecondsRaw);
|
||||||
|
const h = Math.floor(totalSeconds / 3600);
|
||||||
|
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const s = totalSeconds % 60;
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real (wall-clock) time elapsed since the simulation started, as HH:MM:SS — excludes time spent
|
||||||
|
* paused, so it freezes instead of continuing to advance while paused. */
|
||||||
|
function formatElapsed(): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const ongoingPauseMs = pauseStartedAt !== null ? now - pauseStartedAt : 0;
|
||||||
|
return formatDuration((now - startedAt - pausedAccumulatedMs - ongoingPauseMs) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ANSI color codes — only applied when stdout is an actual terminal, so piped/redirected output
|
||||||
|
* (e.g. into a log file) stays plain text instead of full of escape sequences. */
|
||||||
|
const RED = '\x1b[31m';
|
||||||
|
const GOLD = '\x1b[93m';
|
||||||
|
const GRAY = '\x1b[90m';
|
||||||
|
const RESET = '\x1b[0m';
|
||||||
|
|
||||||
|
function colorize(text: string, color: string): string {
|
||||||
|
return process.stdout.isTTY ? `${color}${text}${RESET}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inventory contents, resource names shortened to one emoji each. */
|
||||||
|
function formatInventory(): string {
|
||||||
|
const inv = store.inventory;
|
||||||
|
return `🪵${inv.logs} 🌿${inv.twigs} 🍖${inv.meat} 🪙${inv.coins.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message: string, color?: string): void {
|
||||||
|
const line = `[+${formatElapsed()} | сим ${formatDuration(totalSimulatedSeconds)} | ${store.day}:${formatClock(store.hour)}] ${formatInventory()} ${message}`;
|
||||||
|
console.log(color ? colorize(line, color) : line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logUpgradeLevels(): void {
|
||||||
|
const summary = UPGRADE_LIST.map((def) => `${def.id}=${store.upgradeLevels[def.id]}`).join(' ');
|
||||||
|
console.log(` Апгрейды: ${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buys every upgrade the player can currently afford, cheapest first, until none are — costs
|
||||||
|
* rise after each purchase, so this re-checks from scratch rather than assuming one is enough. */
|
||||||
|
function buyAffordableUpgrades(): void {
|
||||||
|
for (;;) {
|
||||||
|
let cheapestId: UpgradeId | null = null;
|
||||||
|
let cheapestCost = Infinity;
|
||||||
|
for (const def of UPGRADE_LIST) {
|
||||||
|
const cost = costForNextLevel(def, store.upgradeLevels[def.id]);
|
||||||
|
if (store.inventory.coins >= cost && cost < cheapestCost) {
|
||||||
|
cheapestId = def.id;
|
||||||
|
cheapestCost = cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cheapestId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
store.buyUpgrade(cheapestId);
|
||||||
|
const def = UPGRADE_LIST.find((d) => d.id === cheapestId)!;
|
||||||
|
const sincePrevious = totalSimulatedSeconds - lastPurchaseSimulatedSeconds;
|
||||||
|
log(`Куплен апгрейд «${def.name}» за ${cheapestCost.toFixed(1)} — уровень ${store.upgradeLevels[cheapestId]}, с последней покупки ${sincePrevious.toFixed(1)}с`, GOLD);
|
||||||
|
lastPurchaseSimulatedSeconds = totalSimulatedSeconds;
|
||||||
|
logUpgradeLevels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Behavior = 'fleeing' | 'chopping' | 'hunting' | 'idle';
|
||||||
|
let behavior: Behavior = 'idle';
|
||||||
|
let targetId: string | null = null;
|
||||||
|
|
||||||
|
/** Picks what to do this tick and drives the character toward it — only logs when the decision
|
||||||
|
* actually changes (a new target, or switching behavior), not every tick it's re-affirmed. */
|
||||||
|
function decideAction(): void {
|
||||||
|
if (store.isNight) {
|
||||||
|
const rabbit = Rabbit.findNearestToPosition(store.rabbits, store.character);
|
||||||
|
if (rabbit) {
|
||||||
|
// Keyed on the behavior alone, not which rabbit happens to be nearest right now — while
|
||||||
|
// fleeing, the nearest rabbit's identity can flip from tick to tick as the character moves,
|
||||||
|
// which isn't a new action worth a fresh log line the way switching chop targets is.
|
||||||
|
if (behavior !== 'fleeing') {
|
||||||
|
log(`Ночь — убегает от кролика (${Math.round(rabbit.x)}, ${Math.round(rabbit.y)})`);
|
||||||
|
behavior = 'fleeing';
|
||||||
|
}
|
||||||
|
targetId = rabbit.id;
|
||||||
|
const dx = store.character.x - rabbit.x;
|
||||||
|
const dy = store.character.y - rabbit.y;
|
||||||
|
const distance = Math.hypot(dx, dy) || 1;
|
||||||
|
store.moveTo(
|
||||||
|
Math.min(Math.max(store.character.x + (dx / distance) * FLEE_DISTANCE, 0), MAP_WIDTH),
|
||||||
|
Math.min(Math.max(store.character.y + (dy / distance) * FLEE_DISTANCE, 0), MAP_HEIGHT),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No rabbits to flee from — fall through to the normal day-shaped behavior below.
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = ResourceNode.findNearestToPosition(store.nodes, store.character);
|
||||||
|
if (node) {
|
||||||
|
if (behavior !== 'chopping' || targetId !== node.id) {
|
||||||
|
log(`Бежит к ${nodeEmoji(node.kind)} (${Math.round(node.x)}, ${Math.round(node.y)})`);
|
||||||
|
behavior = 'chopping';
|
||||||
|
targetId = node.id;
|
||||||
|
}
|
||||||
|
store.holdNode(node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rabbit = Rabbit.findNearestToPosition(store.rabbits, store.character);
|
||||||
|
if (rabbit) {
|
||||||
|
if (behavior !== 'hunting' || targetId !== rabbit.id) {
|
||||||
|
log(`🌳🌱 нет — охотится на кролика (${Math.round(rabbit.x)}, ${Math.round(rabbit.y)})`);
|
||||||
|
behavior = 'hunting';
|
||||||
|
targetId = rabbit.id;
|
||||||
|
}
|
||||||
|
store.holdRabbit(rabbit.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (behavior !== 'idle') {
|
||||||
|
log('Нечего делать — ждёт.');
|
||||||
|
behavior = 'idle';
|
||||||
|
targetId = null;
|
||||||
|
}
|
||||||
|
store.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Игра началась. Тик = ${TICK_SECONDS}с симуляции, без привязки к реальному времени.`);
|
||||||
|
|
||||||
|
function nodeEmoji(kind: 'tree' | 'bush'): string {
|
||||||
|
return kind === 'tree' ? '🌳' : '🌱';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logs a node's depletion or a rabbit's death — not each individual chop/hit along the way, only
|
||||||
|
* this one-off outcome — snapshotting what's about to be chopped/attacked before the tick, since a
|
||||||
|
* depleted node or killed rabbit is removed from the store's arrays by the time the tick returns. */
|
||||||
|
function logCombatResults(preNode: ReturnType<typeof store.heldNode>, preRabbit: ReturnType<typeof store.heldRabbit>): void {
|
||||||
|
if (preNode && !store.nodes.some((n) => n.id === preNode.id))
|
||||||
|
log(`Срубил ${nodeEmoji(preNode.kind)} полностью`);
|
||||||
|
|
||||||
|
if (preRabbit && !store.rabbits.some((r) => r.id === preRabbit.id))
|
||||||
|
log('Убил кролика');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runOneTick(): void {
|
||||||
|
if (store.isDead) {
|
||||||
|
log('Погиб! Начинается новая игра.', RED);
|
||||||
|
store.initialize(MAP_WIDTH, MAP_HEIGHT);
|
||||||
|
behavior = 'idle';
|
||||||
|
targetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
decideAction();
|
||||||
|
|
||||||
|
const preNode = store.heldNode;
|
||||||
|
const preRabbit = store.heldRabbit;
|
||||||
|
|
||||||
|
store.tick(TICK_SECONDS);
|
||||||
|
totalSimulatedSeconds += TICK_SECONDS;
|
||||||
|
|
||||||
|
logCombatResults(preNode, preRabbit);
|
||||||
|
buyAffordableUpgrades();
|
||||||
|
}
|
||||||
|
|
||||||
|
let running = true;
|
||||||
|
let paused = false;
|
||||||
|
|
||||||
|
function runBatch(): void {
|
||||||
|
if (!running)
|
||||||
|
return;
|
||||||
|
if (!paused) {
|
||||||
|
for (let i = 0; i < TICKS_PER_BATCH; i++)
|
||||||
|
runOneTick();
|
||||||
|
}
|
||||||
|
// Rescheduled even while paused, so a paused run still notices Escape/Ctrl+C/Space promptly
|
||||||
|
// instead of sitting blocked on nothing at all.
|
||||||
|
setImmediate(runBatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImmediate(runBatch);
|
||||||
|
|
||||||
|
/** Every 5 real seconds (not simulated — this fires on a wall-clock timer, independent of how
|
||||||
|
* fast ticks are actually running): time since the last upgrade purchase, this in-game day's
|
||||||
|
* resource cap (how many trees/bushes it allows), and what's actually still on the map. */
|
||||||
|
function printStats(): void {
|
||||||
|
const sinceLastPurchase = totalSimulatedSeconds - lastPurchaseSimulatedSeconds;
|
||||||
|
const treeCap = treesForDay(store.day);
|
||||||
|
const bushCap = bushesForDay(store.day);
|
||||||
|
const treesLeft = store.nodes.filter((n) => n.kind === 'tree').length;
|
||||||
|
const bushesLeft = store.nodes.filter((n) => n.kind === 'bush').length;
|
||||||
|
const logsLeft = store.nodes.reduce((sum, n) => sum + n.remaining.log, 0);
|
||||||
|
const twigsLeft = store.nodes.reduce((sum, n) => sum + n.remaining.twig, 0);
|
||||||
|
|
||||||
|
log(
|
||||||
|
`Последняя покупка ${sinceLastPurchase.toFixed(1)}с назад; `
|
||||||
|
+ `Ресурсы: 🌳${treesLeft}/${treeCap} 🌱${bushesLeft}/${bushCap} 🪵${logsLeft} 🌿${twigsLeft}`,
|
||||||
|
GRAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(printStats, 5000);
|
||||||
|
|
||||||
|
function stop(reason: string): void {
|
||||||
|
running = false;
|
||||||
|
console.log(`\n${reason}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePause(): void {
|
||||||
|
paused = !paused;
|
||||||
|
if (paused) {
|
||||||
|
pauseStartedAt = Date.now();
|
||||||
|
} else if (pauseStartedAt !== null) {
|
||||||
|
pausedAccumulatedMs += Date.now() - pauseStartedAt;
|
||||||
|
pauseStartedAt = null;
|
||||||
|
}
|
||||||
|
log(paused ? 'Пауза (пробел — продолжить, Escape — остановить).' : 'Продолжение.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.stdin.isTTY) {
|
||||||
|
readline.emitKeypressEvents(process.stdin);
|
||||||
|
process.stdin.setRawMode(true);
|
||||||
|
process.stdin.resume();
|
||||||
|
process.stdin.on('keypress', (_str, key) => {
|
||||||
|
if (!key)
|
||||||
|
return;
|
||||||
|
if (key.name === 'space')
|
||||||
|
togglePause();
|
||||||
|
else if (key.name === 'escape')
|
||||||
|
stop('Остановлено пользователем (Escape).');
|
||||||
|
else if (key.ctrl && key.name === 'c')
|
||||||
|
stop('Остановлено пользователем (Ctrl+C).');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log('stdin не терминал — пробел/Escape недоступны, используйте Ctrl+C для остановки.');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => stop('Остановлено пользователем (Ctrl+C).'));
|
||||||
@@ -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));
|
||||||
+16
@@ -318,6 +318,12 @@
|
|||||||
animation: walk-bob 0.3s infinite steps(2);
|
animation: walk-bob 0.3s infinite steps(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same specificity as the walk-bob rule above (and placed after it), so the hit-shake still
|
||||||
|
* shows on the brief flash even if the character happens to be walking at that exact moment. */
|
||||||
|
.entity.character.chopping svg {
|
||||||
|
animation: chop-shake 0.12s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.day-night-overlay {
|
.day-night-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -370,6 +376,16 @@
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-over-overlay__stats {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.8;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes chop-shake {
|
@keyframes chop-shake {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
transform: translate(0, 0) rotate(0deg);
|
transform: translate(0, 0) rotate(0deg);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const CharacterEntity = observer(function CharacterEntity({ store }: { store: Ga
|
|||||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
|
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`entity character ${store.isMoving ? 'character--walking' : ''}`}
|
className={`entity character ${store.isMoving ? 'character--walking' : ''} ${store.characterHitFlashTimer > 0 ? 'chopping' : ''}`}
|
||||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
|
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
|
||||||
>
|
>
|
||||||
<PixelSprite pixels={CHARACTER_SPRITE} size={CHARACTER_SIZE} />
|
<PixelSprite pixels={CHARACTER_SPRITE} size={CHARACTER_SIZE} />
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ interface Props {
|
|||||||
// the app's own (rotated) layout, and have to be converted back explicitly wherever they're used.
|
// the app's own (rotated) layout, and have to be converted back explicitly wherever they're used.
|
||||||
const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)';
|
const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)';
|
||||||
|
|
||||||
|
/** Russian plural agreement for "день" (day) after a count. */
|
||||||
|
function dayWord(n: number): string {
|
||||||
|
const mod10 = n % 10;
|
||||||
|
const mod100 = n % 100;
|
||||||
|
if (mod10 === 1 && mod100 !== 11)
|
||||||
|
return 'день';
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
|
||||||
|
return 'дня';
|
||||||
|
return 'дней';
|
||||||
|
}
|
||||||
|
|
||||||
const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
|
const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
|
||||||
return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
|
return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
|
||||||
});
|
});
|
||||||
@@ -42,8 +53,24 @@ const GameOverOverlay = observer(function GameOverOverlay({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-over-overlay">
|
<div className="game-over-overlay">
|
||||||
<div className="game-over-overlay__message">Кролик поймал вас ночью...</div>
|
<div className="game-over-overlay__message">
|
||||||
<button type="button" className="menu-btn" onClick={onDeath}>
|
Вы прожили {store.day} {dayWord(store.day)}, но вас поймали кролики
|
||||||
|
</div>
|
||||||
|
<div className="game-over-overlay__stats">
|
||||||
|
<div>Пройдено: {Math.round(store.character.distanceTraveled)} px</div>
|
||||||
|
<div>Срублено: {store.treesChoppedCount} деревьев, {store.bushesChoppedCount} кустов</div>
|
||||||
|
<div>Поймано кроликов: {store.rabbitsCaughtCount}</div>
|
||||||
|
<div>Заработано монет: {store.totalCoinsEarned.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="menu-btn"
|
||||||
|
// The whole map underneath (including this overlay) has a pointerdown handler that
|
||||||
|
// captures the pointer and drives character movement — without stopping it here, that
|
||||||
|
// handler fires first on bubble-up and swallows the tap before it becomes a click.
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={onDeath}
|
||||||
|
>
|
||||||
В меню
|
В меню
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +167,15 @@ function GameMap({ store, onDeath }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing's under the cursor right now. Unlike a stationary tree/bush, a rabbit can jump out
|
||||||
|
// from under an otherwise-still cursor entirely on its own — that shouldn't read as "the
|
||||||
|
// player moved off the target" the way it would for a stationary node, or every autonomous
|
||||||
|
// jump would silently drop the attack and send the character off to walk to empty ground
|
||||||
|
// instead. Keep chasing the same rabbit until the player actually points at something else or
|
||||||
|
// releases the button.
|
||||||
|
if (store.heldRabbitId)
|
||||||
|
return;
|
||||||
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const { x, y } = toLocalPoint(e, rect);
|
const { x, y } = toLocalPoint(e, rect);
|
||||||
store.moveTo(x, y);
|
store.moveTo(x, y);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import type { GameStore } from '../game/GameStore';
|
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 { BUSH_SPRITE, TREE_SPRITE } from '../game/sprites';
|
||||||
import PixelSprite from './PixelSprite';
|
import PixelSprite from './PixelSprite';
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import type { GameStore } from '../game/GameStore';
|
import type { GameStore } from '../game/GameStore';
|
||||||
import type { RabbitState } from '../game/types';
|
import type { Rabbit } from '../game/entities/Rabbit';
|
||||||
import { RABBIT_MAX_HP } from '../game/rabbits';
|
|
||||||
import { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../game/sprites';
|
import { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../game/sprites';
|
||||||
import HealthBar from './HealthBar';
|
import HealthBar from './HealthBar';
|
||||||
import PixelSprite from './PixelSprite';
|
import PixelSprite from './PixelSprite';
|
||||||
|
|
||||||
const RABBIT_SIZE = 30;
|
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 isBeingHit = rabbit.id === store.heldRabbitId && store.isAttackingRabbit;
|
|
||||||
const isStriking = rabbit.action === 'striking';
|
|
||||||
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
|
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,14 +17,14 @@ const RabbitEntity = observer(function RabbitEntity({ rabbit, store }: { rabbit:
|
|||||||
style={{ left: rabbit.x, top: rabbit.y, width: size, height: size / 2, marginTop: size * 0.6 }}
|
style={{ left: rabbit.x, top: rabbit.y, width: size, height: size / 2, marginTop: size * 0.6 }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`entity rabbit ${isBeingHit || isStriking ? 'chopping' : ''}`}
|
className={`entity rabbit ${rabbit.hitFlashTimer > 0 ? 'chopping' : ''}`}
|
||||||
style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }}
|
style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }}
|
||||||
data-rabbit-id={rabbit.id}
|
data-rabbit-id={rabbit.id}
|
||||||
>
|
>
|
||||||
<PixelSprite pixels={store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE} size={size} />
|
<PixelSprite pixels={store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE} size={size} />
|
||||||
</div>
|
</div>
|
||||||
{rabbit.hp < RABBIT_MAX_HP && (
|
{rabbit.hp < store.rabbitMaxHp && (
|
||||||
<HealthBar x={rabbit.x} y={rabbit.y - rabbit.z - size * 0.5 - 6} ratio={rabbit.hp / RABBIT_MAX_HP} />
|
<HealthBar x={rabbit.x} y={rabbit.y - rabbit.z - size * 0.5 - 6} ratio={rabbit.hp / store.rabbitMaxHp} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { observer } from 'mobx-react-lite';
|
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 { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||||
import PixelSprite from './PixelSprite';
|
import PixelSprite from './PixelSprite';
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ function Sidebar({ store }: Props) {
|
|||||||
<span className="upgrade-btn__level">Ур. {level}</span>
|
<span className="upgrade-btn__level">Ур. {level}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="upgrade-btn__effects">
|
<div className="upgrade-btn__effects">
|
||||||
<span>{def.formatEffect(def.effectAtLevel(level))}</span>
|
<span>{def.formatEffect(def.effectAtLevel(level), level)}</span>
|
||||||
<span className="upgrade-btn__arrow">></span>
|
<span className="upgrade-btn__arrow">></span>
|
||||||
<span>{def.formatEffect(def.effectAtLevel(level + 1))}</span>
|
<span>{def.formatEffect(def.effectAtLevel(level + 1), level + 1)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="upgrade-btn__cost">
|
<div className="upgrade-btn__cost">
|
||||||
<PixelSprite pixels={COIN_SPRITE} size={16} />
|
<PixelSprite pixels={COIN_SPRITE} size={16} />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function Topbar({ store, onExitToMenu }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="inventory-slot">
|
<div className="inventory-slot">
|
||||||
<PixelSprite pixels={HEART_SPRITE} size={18} />
|
<PixelSprite pixels={HEART_SPRITE} size={18} />
|
||||||
<span>{store.characterHp}/{store.characterMaxHp}</span>
|
<span>{store.characterHp.toFixed(1)}/{store.characterMaxHp}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
<div className="inventory-slot">
|
<div className="inventory-slot">
|
||||||
|
|||||||
+259
-439
@@ -1,74 +1,64 @@
|
|||||||
import { makeAutoObservable } from 'mobx';
|
import { makeAutoObservable } from 'mobx';
|
||||||
import type { Inventory, NodeResourceType, RabbitState, ResourceDrop, ResourceNode, ResourceType, Vec2 } from './types';
|
import { HIT_FLASH_DURATION, INTERACTION_RADIUS, bushesForDay, treesForDay } from './constants';
|
||||||
import {
|
import { BLOOD_MOON_BANNER_SECONDS, DAY_HOURS, isBloodMoonNight, isNightHour, NIGHT_SPEED, nightBelongsToDay } from './daynight';
|
||||||
bushesForDay,
|
import { Character } from './entities/Character';
|
||||||
DROP_COLLECT_ARC_HEIGHT,
|
import { Rabbit } from './entities/Rabbit';
|
||||||
DROP_COLLECT_DURATION,
|
import type { RabbitTickContext } from './entities/Rabbit';
|
||||||
DROP_EJECT_RADIUS,
|
import { ResourceDrop } from './entities/ResourceDrop';
|
||||||
DROP_FALL_ARC_HEIGHT,
|
import { ResourceNode } from './entities/ResourceNode';
|
||||||
DROP_FALL_DURATION,
|
|
||||||
ENTITY_EDGE_MARGIN,
|
|
||||||
ENTITY_MIN_DISTANCE_BETWEEN,
|
|
||||||
ENTITY_MIN_DISTANCE_FROM_CENTER,
|
|
||||||
INTERACTION_RADIUS,
|
|
||||||
LOGS_PER_TREE,
|
|
||||||
treesForDay,
|
|
||||||
TWIGS_PER_BUSH,
|
|
||||||
TWIGS_PER_TREE,
|
|
||||||
} from './constants';
|
|
||||||
import { BLOOD_MOON_BANNER_SECONDS, isBloodMoonNight, isNightHour, NIGHT_SPEED, nightBelongsToDay } from './daynight';
|
|
||||||
import { clearSave, loadSave, writeSave } from './persistence';
|
import { clearSave, loadSave, writeSave } from './persistence';
|
||||||
import {
|
import {
|
||||||
|
BLOOD_MOON_COMBAT_MULTIPLIER,
|
||||||
BLOOD_MOON_SIZE_MULTIPLIER,
|
BLOOD_MOON_SIZE_MULTIPLIER,
|
||||||
MEAT_VALUE,
|
RABBIT_HEALTH_REGEN_INTERVAL,
|
||||||
RABBIT_ACTION_DURATION,
|
|
||||||
RABBIT_HIT_RADIUS,
|
RABBIT_HIT_RADIUS,
|
||||||
RABBIT_JUMP_ARC_HEIGHT,
|
|
||||||
RABBIT_JUMP_DISTANCE,
|
|
||||||
RABBIT_MAX_HP,
|
RABBIT_MAX_HP,
|
||||||
RABBIT_MIN_DISTANCE_FROM_CHARACTER,
|
|
||||||
RABBIT_NIGHT_JUMP_SPREAD_DEGREES,
|
|
||||||
RABBIT_PAUSE_DURATION,
|
|
||||||
RABBIT_STRIKE_DAMAGE,
|
RABBIT_STRIKE_DAMAGE,
|
||||||
RABBIT_STRIKE_RANGE,
|
RABBIT_STRIKE_RANGE,
|
||||||
rabbitsForDay,
|
rabbitsForDay,
|
||||||
} from './rabbits';
|
} from './rabbits';
|
||||||
|
import { pickSpawnPositions } from './spawning';
|
||||||
|
import type { Inventory, ResourceType, Vec2 } from './types';
|
||||||
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
|
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
|
||||||
|
|
||||||
/** How often (seconds of played time) the game autosaves while running. */
|
/** How often (seconds of played time) the game autosaves while running. */
|
||||||
const AUTOSAVE_INTERVAL = 2;
|
const AUTOSAVE_INTERVAL = 2;
|
||||||
|
|
||||||
/** Hp regenerated per second while the character isn't currently being bitten. */
|
/** Every upgrade starts at level 1 on a fresh game. */
|
||||||
const HEALTH_REGEN_PER_SECOND = 1;
|
const DEFAULT_UPGRADE_LEVELS: Record<UpgradeId, number> = {
|
||||||
|
moveSpeed: 1,
|
||||||
/** A node yields its logs before its twigs. */
|
chopSpeed: 1,
|
||||||
const RESOURCE_ORDER: NodeResourceType[] = ['log', 'twig'];
|
logValue: 1,
|
||||||
|
twigValue: 1,
|
||||||
function randomId(prefix: string): string {
|
meatValue: 1,
|
||||||
return `${prefix}-${Math.random().toString(36).slice(2)}`;
|
pickupRadius: 1,
|
||||||
}
|
coinSpeed: 1,
|
||||||
|
dayLength: 1,
|
||||||
function isDepleted(node: ResourceNode): boolean {
|
maxHealth: 1,
|
||||||
return RESOURCE_ORDER.every((resource) => node.remaining[resource] <= 0);
|
attackDamage: 1,
|
||||||
}
|
healthRegenSpeed: 1,
|
||||||
|
};
|
||||||
/** 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 {
|
export class GameStore {
|
||||||
day = 1;
|
day = 1;
|
||||||
/** In-game clock time (0-24, fractional), starts at noon on a fresh game. */
|
/** In-game clock time (0-24, fractional), starts at noon on a fresh game. */
|
||||||
hour = 12;
|
hour = 12;
|
||||||
character: Vec2 = { x: 0, y: 0 };
|
character: Character = new Character(0, 0, 5);
|
||||||
nodes: ResourceNode[] = [];
|
nodes: ResourceNode[] = [];
|
||||||
resourceDrops: ResourceDrop[] = [];
|
resourceDrops: ResourceDrop[] = [];
|
||||||
rabbits: RabbitState[] = [];
|
rabbits: Rabbit[] = [];
|
||||||
/** Set once the character's hp reaches 0 — freezes the game (see tick()). */
|
/** Set once the character's hp reaches 0 — freezes the game (see tick()). */
|
||||||
isDead = false;
|
isDead = false;
|
||||||
/** Current hp; starts each fresh game at characterMaxHp. */
|
/** Seconds left to play the character's hit-shake animation — set only the instant a rabbit's
|
||||||
characterHp = 5;
|
* strike actually lands, not for the whole time it's under threat. */
|
||||||
|
characterHitFlashTimer = 0;
|
||||||
inventory: Inventory = { logs: 0, twigs: 0, meat: 0, coins: 0 };
|
inventory: Inventory = { logs: 0, twigs: 0, meat: 0, coins: 0 };
|
||||||
heldNodeId: string | null = null;
|
heldNodeId: string | null = null;
|
||||||
heldRabbitId: string | null = null;
|
heldRabbitId: string | null = null;
|
||||||
@@ -77,21 +67,17 @@ export class GameStore {
|
|||||||
moveTarget: Vec2 | null = null;
|
moveTarget: Vec2 | null = null;
|
||||||
/** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */
|
/** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */
|
||||||
bloodMoonBannerTimer = 0;
|
bloodMoonBannerTimer = 0;
|
||||||
upgradeLevels: Record<UpgradeId, number> = {
|
upgradeLevels: Record<UpgradeId, number> = { ...DEFAULT_UPGRADE_LEVELS };
|
||||||
moveSpeed: 1,
|
|
||||||
chopSpeed: 1,
|
// This life's run-summary stats, shown on the death screen — reset to 0 on a fresh game,
|
||||||
logValue: 1,
|
// restored from the save when continuing one (see initialize()).
|
||||||
twigValue: 1,
|
treesChoppedCount = 0;
|
||||||
pickupRadius: 1,
|
bushesChoppedCount = 0;
|
||||||
coinSpeed: 1,
|
rabbitsCaughtCount = 0;
|
||||||
daySpeed: 1,
|
/** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes
|
||||||
maxHealth: 1,
|
* down when coins are spent on upgrades. */
|
||||||
attackDamage: 1,
|
totalCoinsEarned = 0;
|
||||||
};
|
|
||||||
|
|
||||||
private chopProgress = 0;
|
|
||||||
private rabbitAttackProgress = 0;
|
|
||||||
private healthRegenProgress = 0;
|
|
||||||
private coinProgress = 0;
|
private coinProgress = 0;
|
||||||
private saveProgress = 0;
|
private saveProgress = 0;
|
||||||
private lastTimestamp: number | null = null;
|
private lastTimestamp: number | null = null;
|
||||||
@@ -114,11 +100,16 @@ export class GameStore {
|
|||||||
makeAutoObservable(this, {}, { autoBind: true });
|
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 {
|
get heldNode(): ResourceNode | undefined {
|
||||||
return this.nodes.find((node) => node.id === this.heldNodeId);
|
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);
|
return this.rabbits.find((rabbit) => rabbit.id === this.heldRabbitId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +157,10 @@ export class GameStore {
|
|||||||
return UPGRADES.twigValue.effectAtLevel(this.upgradeLevels.twigValue);
|
return UPGRADES.twigValue.effectAtLevel(this.upgradeLevels.twigValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get coinsPerMeat(): number {
|
||||||
|
return UPGRADES.meatValue.effectAtLevel(this.upgradeLevels.meatValue);
|
||||||
|
}
|
||||||
|
|
||||||
get pickupRadiusPx(): number {
|
get pickupRadiusPx(): number {
|
||||||
return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius);
|
return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius);
|
||||||
}
|
}
|
||||||
@@ -175,17 +170,27 @@ export class GameStore {
|
|||||||
return UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
|
return UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Game-hours per real second while it's day (morning/day/evening) — night has its own fixed speed. */
|
/** Game-hours per real second while it's day (morning/day/evening) — night has its own fixed speed.
|
||||||
|
* `dayLength`'s effect is the real-seconds duration of the whole day phase (see its formatEffect,
|
||||||
|
* "N с"), so this has to divide DAY_HOURS by it — not invert the seconds value on its own, which
|
||||||
|
* previously left DAY_HOURS out of the formula entirely and made every day phase take vastly
|
||||||
|
* longer than the "N с" the upgrade advertises. */
|
||||||
get daySpeedHoursPerSecond(): number {
|
get daySpeedHoursPerSecond(): number {
|
||||||
return UPGRADES.daySpeed.effectAtLevel(this.upgradeLevels.daySpeed);
|
const dayLengthSeconds = UPGRADES.dayLength.effectAtLevel(this.upgradeLevels.dayLength);
|
||||||
|
return DAY_HOURS / dayLengthSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
get characterMaxHp(): number {
|
get characterMaxHp(): number {
|
||||||
return UPGRADES.maxHealth.effectAtLevel(this.upgradeLevels.maxHealth);
|
return UPGRADES.maxHealth.effectAtLevel(this.upgradeLevels.maxHealth);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Damage per second the character deals to a rabbit it's attacking. */
|
/** Seconds per regenerated hp for the character. */
|
||||||
get attackDamagePerSecond(): number {
|
get healthRegenInterval(): number {
|
||||||
|
return UPGRADES.healthRegenSpeed.effectAtLevel(this.upgradeLevels.healthRegenSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Damage dealt per swing when attacking a rabbit — see chopInterval for the swing cadence. */
|
||||||
|
get attackDamagePerHit(): number {
|
||||||
return UPGRADES.attackDamage.effectAtLevel(this.upgradeLevels.attackDamage);
|
return UPGRADES.attackDamage.effectAtLevel(this.upgradeLevels.attackDamage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +218,17 @@ export class GameStore {
|
|||||||
return RABBIT_HIT_RADIUS * (this.isBloodMoon ? BLOOD_MOON_SIZE_MULTIPLIER : 1);
|
return RABBIT_HIT_RADIUS * (this.isBloodMoon ? BLOOD_MOON_SIZE_MULTIPLIER : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A rabbit's max hp — during a blood moon they're tougher, not just bigger. */
|
||||||
|
get rabbitMaxHp(): number {
|
||||||
|
return RABBIT_MAX_HP * (this.isBloodMoon ? BLOOD_MOON_COMBAT_MULTIPLIER : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Damage a rabbit's strike deals to the character — during a blood moon they hit harder, not
|
||||||
|
* just bigger. */
|
||||||
|
get rabbitStrikeDamage(): number {
|
||||||
|
return RABBIT_STRIKE_DAMAGE * (this.isBloodMoon ? BLOOD_MOON_COMBAT_MULTIPLIER : 1);
|
||||||
|
}
|
||||||
|
|
||||||
/** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */
|
/** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */
|
||||||
buyUpgrade(id: UpgradeId): boolean {
|
buyUpgrade(id: UpgradeId): boolean {
|
||||||
const level = this.upgradeLevels[id];
|
const level = this.upgradeLevels[id];
|
||||||
@@ -233,12 +249,9 @@ export class GameStore {
|
|||||||
this.heldNodeId = null;
|
this.heldNodeId = null;
|
||||||
this.heldRabbitId = null;
|
this.heldRabbitId = null;
|
||||||
this.moveTarget = null;
|
this.moveTarget = null;
|
||||||
this.chopProgress = 0;
|
|
||||||
this.rabbitAttackProgress = 0;
|
|
||||||
this.healthRegenProgress = 0;
|
|
||||||
this.coinProgress = 0;
|
|
||||||
this.saveProgress = 0;
|
this.saveProgress = 0;
|
||||||
this.isDead = false;
|
this.isDead = false;
|
||||||
|
this.characterHitFlashTimer = 0;
|
||||||
this.bloodMoonBannerTimer = 0;
|
this.bloodMoonBannerTimer = 0;
|
||||||
this.lastAnnouncedBloodMoonDay = -1;
|
this.lastAnnouncedBloodMoonDay = -1;
|
||||||
|
|
||||||
@@ -246,48 +259,38 @@ export class GameStore {
|
|||||||
this.day = save?.day ?? 1;
|
this.day = save?.day ?? 1;
|
||||||
this.hour = save?.hour ?? 12;
|
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) {
|
if (save?.character && save.nodes && save.resourceDrops) {
|
||||||
this.character = { ...save.character };
|
this.character = new Character(save.character.x, save.character.y, save.characterHp ?? this.characterMaxHp, save.distanceTraveled ?? 0);
|
||||||
this.nodes = save.nodes.map((node) => ({ ...node, remaining: { ...node.remaining } }));
|
this.nodes = save.nodes.map((node) => ResourceNode.restore(node));
|
||||||
this.resourceDrops = save.resourceDrops.map((drop) => ({ ...drop, flight: drop.flight ? { ...drop.flight } : undefined }));
|
this.resourceDrops = save.resourceDrops.map((drop) => ResourceDrop.restore(drop));
|
||||||
} else {
|
} 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.nodes = this.spawnNodes(width, height);
|
||||||
this.resourceDrops = [];
|
this.resourceDrops = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This life's run-summary stats — restored from the save when continuing a life, reset to 0
|
||||||
|
// for a fresh one (matching the character branch above, since both represent the same life).
|
||||||
|
this.treesChoppedCount = save?.treesChoppedCount ?? 0;
|
||||||
|
this.bushesChoppedCount = save?.bushesChoppedCount ?? 0;
|
||||||
|
this.rabbitsCaughtCount = save?.rabbitsCaughtCount ?? 0;
|
||||||
|
this.totalCoinsEarned = save?.totalCoinsEarned ?? 0;
|
||||||
|
|
||||||
// Rabbits are restored/spawned independently of the world-state branch above so a save from
|
// 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.
|
// 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
|
this.rabbits = save?.rabbits
|
||||||
? save.rabbits.map((rabbit) => ({
|
? save.rabbits.map((rabbit) => Rabbit.restore(rabbit, this.rabbitMaxHp))
|
||||||
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: Math.round(rabbit.hp ?? RABBIT_MAX_HP),
|
|
||||||
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,
|
|
||||||
}))
|
|
||||||
: this.spawnRabbits(width, height, rabbitsForDay(this.day));
|
: this.spawnRabbits(width, height, rabbitsForDay(this.day));
|
||||||
|
|
||||||
// Merge over defaults rather than trusting the save's shape outright, so a save from before
|
// 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.
|
// 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 };
|
this.inventory = { logs: 0, twigs: 0, meat: 0, coins: 0, ...save?.inventory };
|
||||||
if (save)
|
|
||||||
this.upgradeLevels = { ...this.upgradeLevels, ...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. Rounding covers a save
|
|
||||||
// from when hp could still go fractional.
|
|
||||||
this.characterHp = Math.round(save?.characterHp ?? this.characterMaxHp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists the full game (world state + progress) to localStorage right away. */
|
/** Persists the full game (world state + progress) to localStorage right away. */
|
||||||
@@ -296,22 +299,52 @@ export class GameStore {
|
|||||||
version: 3,
|
version: 3,
|
||||||
inventory: { ...this.inventory },
|
inventory: { ...this.inventory },
|
||||||
upgradeLevels: { ...this.upgradeLevels },
|
upgradeLevels: { ...this.upgradeLevels },
|
||||||
character: { ...this.character },
|
character: { x: this.character.x, y: this.character.y },
|
||||||
nodes: this.nodes.map((node) => ({ ...node, remaining: { ...node.remaining } })),
|
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) => ({ ...drop, flight: drop.flight ? { ...drop.flight } : undefined })),
|
resourceDrops: this.resourceDrops.map((drop) => ({
|
||||||
rabbits: this.rabbits.map((rabbit) => ({ ...rabbit })),
|
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,
|
day: this.day,
|
||||||
hour: this.hour,
|
hour: this.hour,
|
||||||
characterHp: this.characterHp,
|
characterHp: this.character.hp,
|
||||||
|
distanceTraveled: this.character.distanceTraveled,
|
||||||
|
treesChoppedCount: this.treesChoppedCount,
|
||||||
|
bushesChoppedCount: this.bushesChoppedCount,
|
||||||
|
rabbitsCaughtCount: this.rabbitsCaughtCount,
|
||||||
|
totalCoinsEarned: this.totalCoinsEarned,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scatters day 1's trees and bushes, keeping every node clear of the character's starting spot,
|
/** 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. */
|
* the map edge, and each other regardless of kind. */
|
||||||
private spawnNodes(width: number, height: number): ResourceNode[] {
|
private spawnNodes(width: number, height: number): ResourceNode[] {
|
||||||
const treePositions = this.spawnPositions(treesForDay(this.day), width, height, []);
|
const treePositions = pickSpawnPositions(treesForDay(this.day), width, height, []);
|
||||||
const bushPositions = this.spawnPositions(bushesForDay(this.day), width, height, treePositions);
|
const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions);
|
||||||
return [...this.buildTrees(treePositions), ...this.buildBushes(bushPositions)];
|
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
|
/** A new day doesn't clear existing, unharvested plants — it only tops each kind up (at random
|
||||||
@@ -325,17 +358,20 @@ export class GameStore {
|
|||||||
const treeDeficit = Math.max(0, treesForDay(this.day) - this.nodes.filter((n) => n.kind === 'tree').length);
|
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 bushDeficit = Math.max(0, bushesForDay(this.day) - this.nodes.filter((n) => n.kind === 'bush').length);
|
||||||
|
|
||||||
const newTreePositions = this.spawnPositions(treeDeficit, width, height, existing);
|
const newTreePositions = pickSpawnPositions(treeDeficit, width, height, existing);
|
||||||
const newBushPositions = this.spawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions]);
|
const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions]);
|
||||||
const newNodes = [...this.buildTrees(newTreePositions), ...this.buildBushes(newBushPositions)];
|
const newNodes = [
|
||||||
|
...newTreePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
|
||||||
|
...newBushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)),
|
||||||
|
];
|
||||||
|
|
||||||
if (newNodes.length > 0)
|
if (newNodes.length > 0)
|
||||||
this.nodes = [...this.nodes, ...newNodes];
|
this.nodes = [...this.nodes, ...newNodes];
|
||||||
}
|
}
|
||||||
|
|
||||||
private spawnRabbits(width: number, height: number, count: number): RabbitState[] {
|
private spawnRabbits(width: number, height: number, count: number): Rabbit[] {
|
||||||
const positions = this.spawnPositions(count, width, height, []);
|
const positions = pickSpawnPositions(count, width, height, []);
|
||||||
return positions.map((pos) => this.buildRabbit(pos));
|
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
|
/** Tops the rabbit population up to this day's cap (never removes any — the cap only ever
|
||||||
@@ -349,76 +385,15 @@ export class GameStore {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y }));
|
const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y }));
|
||||||
const positions = this.spawnPositions(deficit, width, height, existing);
|
const positions = pickSpawnPositions(deficit, width, height, existing);
|
||||||
this.rabbits = [...this.rabbits, ...positions.map((pos) => this.buildRabbit(pos))];
|
this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp))];
|
||||||
}
|
|
||||||
|
|
||||||
private buildRabbit(pos: Vec2): RabbitState {
|
|
||||||
return {
|
|
||||||
id: randomId('rabbit'),
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y,
|
|
||||||
z: 0,
|
|
||||||
hp: RABBIT_MAX_HP,
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
holdNode(id: string) {
|
holdNode(id: string) {
|
||||||
if (this.nodes.some((node) => node.id === id)) {
|
if (this.nodes.some((node) => node.id === id)) {
|
||||||
this.moveTarget = null;
|
this.moveTarget = null;
|
||||||
this.heldRabbitId = null;
|
this.heldRabbitId = null;
|
||||||
this.rabbitAttackProgress = 0;
|
this.character.resetAttackProgress();
|
||||||
this.heldNodeId = id;
|
this.heldNodeId = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,7 +402,11 @@ export class GameStore {
|
|||||||
if (this.rabbits.some((rabbit) => rabbit.id === id)) {
|
if (this.rabbits.some((rabbit) => rabbit.id === id)) {
|
||||||
this.moveTarget = null;
|
this.moveTarget = null;
|
||||||
this.heldNodeId = null;
|
this.heldNodeId = null;
|
||||||
this.rabbitAttackProgress = 0;
|
// Only reset progress when actually switching targets — re-affirming the same rabbit
|
||||||
|
// (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.character.resetAttackProgress();
|
||||||
this.heldRabbitId = id;
|
this.heldRabbitId = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,8 +415,8 @@ export class GameStore {
|
|||||||
moveTo(x: number, y: number) {
|
moveTo(x: number, y: number) {
|
||||||
this.heldNodeId = null;
|
this.heldNodeId = null;
|
||||||
this.heldRabbitId = null;
|
this.heldRabbitId = null;
|
||||||
this.chopProgress = 0;
|
this.character.resetChopProgress();
|
||||||
this.rabbitAttackProgress = 0;
|
this.character.resetAttackProgress();
|
||||||
this.moveTarget = { x, y };
|
this.moveTarget = { x, y };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,8 +424,8 @@ export class GameStore {
|
|||||||
this.heldNodeId = null;
|
this.heldNodeId = null;
|
||||||
this.heldRabbitId = null;
|
this.heldRabbitId = null;
|
||||||
this.moveTarget = null;
|
this.moveTarget = null;
|
||||||
this.chopProgress = 0;
|
this.character.resetChopProgress();
|
||||||
this.rabbitAttackProgress = 0;
|
this.character.resetAttackProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@@ -474,7 +453,11 @@ export class GameStore {
|
|||||||
this.lastTimestamp = null;
|
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)
|
if (this.isDead)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -482,14 +465,26 @@ export class GameStore {
|
|||||||
this.updateBloodMoon(deltaTime);
|
this.updateBloodMoon(deltaTime);
|
||||||
this.updateMovementAndChopping(deltaTime);
|
this.updateMovementAndChopping(deltaTime);
|
||||||
this.updateRabbits(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);
|
this.updateDropFlights(deltaTime);
|
||||||
this.updatePickups();
|
this.updatePickups();
|
||||||
this.updateCoinConversion(deltaTime);
|
this.updateCoinConversion(deltaTime);
|
||||||
this.updateAutosave(deltaTime);
|
this.updateAutosave(deltaTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows the "КРОВАВАЯ ЛУНА!" banner for a few seconds right as a blood moon night begins. */
|
/** 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) {
|
private updateBloodMoon(deltaTime: number) {
|
||||||
if (this.bloodMoonBannerTimer > 0)
|
if (this.bloodMoonBannerTimer > 0)
|
||||||
this.bloodMoonBannerTimer = Math.max(0, this.bloodMoonBannerTimer - deltaTime);
|
this.bloodMoonBannerTimer = Math.max(0, this.bloodMoonBannerTimer - deltaTime);
|
||||||
@@ -501,35 +496,19 @@ export class GameStore {
|
|||||||
return;
|
return;
|
||||||
this.lastAnnouncedBloodMoonDay = nightDay;
|
this.lastAnnouncedBloodMoonDay = nightDay;
|
||||||
this.bloodMoonBannerTimer = BLOOD_MOON_BANNER_SECONDS;
|
this.bloodMoonBannerTimer = BLOOD_MOON_BANNER_SECONDS;
|
||||||
|
const maxHp = this.rabbitMaxHp;
|
||||||
|
for (const rabbit of this.rabbits)
|
||||||
|
rabbit.healToFull(maxHp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hp regenerates a whole point at a time (same accumulate-then-spend cadence as chopProgress,
|
/** A rabbit's own damage lands the instant its strike action begins (see Rabbit.doNextAction)
|
||||||
* coinProgress, ...) while no rabbit is close enough, at night, to strike — regenerating while
|
* rather than draining continuously or waiting for the action to play out — the ~1s of
|
||||||
* actively hunted would undercut the threat, so proximity alone pauses it even between one
|
* "striking" afterward is just the rabbit committed to that action and unable to do anything
|
||||||
* rabbit's individual strikes. Actual damage happens elsewhere, in landRabbitStrike, once a
|
* else, not a wind-up the character could still escape. */
|
||||||
* rabbit's own strike action completes. */
|
private landRabbitStrike(): void {
|
||||||
private updateHealthRegen(deltaTime: number) {
|
this.character.takeDamage(this.rabbitStrikeDamage);
|
||||||
const isThreatened = this.isNight && this.rabbits.some(
|
this.characterHitFlashTimer = HIT_FLASH_DURATION;
|
||||||
(rabbit) => Math.hypot(rabbit.x - this.character.x, rabbit.y - this.character.y) <= this.rabbitStrikeRange,
|
if (this.character.hp <= 0) {
|
||||||
);
|
|
||||||
if (isThreatened || this.characterHp >= this.characterMaxHp) {
|
|
||||||
this.healthRegenProgress = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.healthRegenProgress += deltaTime;
|
|
||||||
while (this.healthRegenProgress >= 1 / HEALTH_REGEN_PER_SECOND && this.characterHp < this.characterMaxHp) {
|
|
||||||
this.healthRegenProgress -= 1 / HEALTH_REGEN_PER_SECOND;
|
|
||||||
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 - RABBIT_STRIKE_DAMAGE);
|
|
||||||
if (this.characterHp <= 0) {
|
|
||||||
this.isDead = true;
|
this.isDead = true;
|
||||||
clearSave();
|
clearSave();
|
||||||
}
|
}
|
||||||
@@ -549,117 +528,26 @@ export class GameStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Each rabbit independently cycles: pause (~1s, deciding its next action) → jump or strike
|
/** Each rabbit independently cycles: pause (deciding its next action) → jump or strike
|
||||||
* (~1s, committed — see updateRabbitAction) → pause → ... A rabbit does nothing but the current
|
* (committed — see Rabbit.update) → pause → ... reacting to the character's position only at
|
||||||
* action while it's mid-action; it only reacts to the character's position at the instant each
|
* the instant each pause ends. */
|
||||||
* pause ends. */
|
|
||||||
private updateRabbits(deltaTime: number) {
|
private updateRabbits(deltaTime: number) {
|
||||||
if (this.rabbits.length === 0 || !this.mapSize)
|
if (this.rabbits.length === 0 || !this.mapSize)
|
||||||
return;
|
return;
|
||||||
for (const rabbit of this.rabbits)
|
|
||||||
this.updateRabbitAction(rabbit, deltaTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateRabbitAction(rabbit: RabbitState, deltaTime: number) {
|
const maxHp = this.rabbitMaxHp;
|
||||||
rabbit.actionTime += deltaTime;
|
const ctx: RabbitTickContext = {
|
||||||
|
characterPos: this.character,
|
||||||
if (rabbit.action === 'idle') {
|
isNight: this.isNight,
|
||||||
if (rabbit.actionTime < RABBIT_PAUSE_DURATION)
|
strikeRange: this.rabbitStrikeRange,
|
||||||
return;
|
mapWidth: this.mapSize.width,
|
||||||
rabbit.actionTime -= RABBIT_PAUSE_DURATION;
|
mapHeight: this.mapSize.height,
|
||||||
this.startNextRabbitAction(rabbit);
|
onStrike: () => this.landRabbitStrike(),
|
||||||
return;
|
};
|
||||||
|
for (const rabbit of this.rabbits) {
|
||||||
|
rabbit.regenerateHealth(deltaTime, RABBIT_HEALTH_REGEN_INTERVAL, maxHp);
|
||||||
|
rabbit.update(deltaTime, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
private updateAutosave(deltaTime: number) {
|
||||||
@@ -684,75 +572,51 @@ export class GameStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.moveTarget) {
|
if (this.moveTarget) {
|
||||||
this.walkTowardPoint(this.moveTarget, deltaTime);
|
this.character.moveToPosition(this.moveTarget, this.characterSpeed, deltaTime, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.chopProgress = 0;
|
this.character.resetChopProgress();
|
||||||
}
|
|
||||||
|
|
||||||
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 walkTowardNode(node: ResourceNode, deltaTime: number) {
|
private walkTowardNode(node: ResourceNode, deltaTime: number) {
|
||||||
const dx = node.x - this.character.x;
|
const distance = Math.hypot(node.x - this.character.x, node.y - this.character.y);
|
||||||
const dy = node.y - this.character.y;
|
|
||||||
const distance = Math.hypot(dx, dy);
|
|
||||||
|
|
||||||
// A small tolerance keeps the character from flip-flopping between "walking" and "chopping"
|
// 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
|
// 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
|
// the movement below can otherwise cause (each flip resets chopProgress, so chopping could
|
||||||
// could stall forever right at the boundary).
|
// stall forever right at the boundary).
|
||||||
if (distance > INTERACTION_RADIUS + 0.5) {
|
if (distance > INTERACTION_RADIUS + 0.5) {
|
||||||
const step = Math.min(this.characterSpeed * deltaTime, distance - INTERACTION_RADIUS);
|
this.character.moveToPosition(node, this.characterSpeed, deltaTime, INTERACTION_RADIUS);
|
||||||
const ratio = step / distance;
|
this.character.resetChopProgress();
|
||||||
this.character = {
|
|
||||||
x: this.character.x + dx * ratio,
|
|
||||||
y: this.character.y + dy * ratio,
|
|
||||||
};
|
|
||||||
this.chopProgress = 0;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chopInterval = this.chopInterval;
|
const chopInterval = this.chopInterval;
|
||||||
this.chopProgress += deltaTime;
|
let swings = this.character.accumulateChopSwings(deltaTime, chopInterval);
|
||||||
while (this.chopProgress >= chopInterval && !isDepleted(node)) {
|
while (swings > 0 && !node.isDepleted) {
|
||||||
this.chopProgress -= chopInterval;
|
swings--;
|
||||||
const resource = nextResource(node);
|
const resource = node.chopOnce();
|
||||||
if (!resource)
|
if (!resource)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
node.remaining[resource] -= 1;
|
|
||||||
this.ejectDrop(node, resource);
|
this.ejectDrop(node, resource);
|
||||||
|
|
||||||
if (isDepleted(node)) {
|
if (node.isDepleted) {
|
||||||
this.nodes = this.nodes.filter((n) => n.id !== node.id);
|
this.nodes = this.nodes.filter((n) => n.id !== node.id);
|
||||||
this.heldNodeId = null;
|
this.heldNodeId = null;
|
||||||
this.chopProgress = 0;
|
this.character.resetChopProgress();
|
||||||
|
if (node.kind === 'tree')
|
||||||
|
this.treesChoppedCount += 1;
|
||||||
|
else
|
||||||
|
this.bushesChoppedCount += 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private walkTowardRabbit(rabbit: RabbitState, deltaTime: number) {
|
private walkTowardRabbit(rabbit: Rabbit, deltaTime: number) {
|
||||||
const dx = rabbit.x - this.character.x;
|
const distance = Math.hypot(rabbit.x - this.character.x, rabbit.y - this.character.y);
|
||||||
const dy = rabbit.y - this.character.y;
|
|
||||||
const distance = Math.hypot(dx, dy);
|
|
||||||
const hitRadius = this.rabbitHitRadius;
|
const hitRadius = this.rabbitHitRadius;
|
||||||
|
|
||||||
// Unlike a stationary node, a rabbit keeps moving after the character reaches hitting
|
// Unlike a stationary node, a rabbit keeps moving after the character reaches hitting
|
||||||
@@ -760,24 +624,25 @@ export class GameStore {
|
|||||||
// the moment it's nominally "close enough." Always closing the remaining gap (rather than
|
// 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
|
// parking at hitRadius the way walkTowardNode does at INTERACTION_RADIUS) keeps the chase
|
||||||
// from stalling out just past the boundary every other frame.
|
// from stalling out just past the boundary every other frame.
|
||||||
if (distance > 0.5) {
|
if (distance > 0.5)
|
||||||
const step = Math.min(this.characterSpeed * deltaTime, distance);
|
this.character.moveToPosition(rabbit, this.characterSpeed, deltaTime, 0);
|
||||||
const ratio = step / distance;
|
|
||||||
this.character = {
|
|
||||||
x: this.character.x + dx * ratio,
|
|
||||||
y: this.character.y + dy * ratio,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distance > hitRadius) {
|
if (distance > hitRadius) {
|
||||||
this.rabbitAttackProgress = 0;
|
this.character.resetAttackProgress();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rabbitAttackProgress += this.attackDamagePerSecond * deltaTime;
|
// Same discrete swing-cadence pattern as walkTowardNode: chopSpeed sets how often a swing
|
||||||
while (this.rabbitAttackProgress >= 1 && rabbit.hp > 0) {
|
// lands, attackDamage sets how hard each one hits — so a swing dealing >= a rabbit's hp
|
||||||
this.rabbitAttackProgress -= 1;
|
// (e.g. 3 dmg/hit against a 3-hp rabbit) actually one-shots it, instead of that damage being
|
||||||
rabbit.hp -= 1;
|
// smeared out continuously over a whole second.
|
||||||
|
const swingInterval = this.chopInterval;
|
||||||
|
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) {
|
if (rabbit.hp <= 0) {
|
||||||
this.killRabbit(rabbit);
|
this.killRabbit(rabbit);
|
||||||
break;
|
break;
|
||||||
@@ -785,80 +650,41 @@ export class GameStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private killRabbit(rabbit: RabbitState) {
|
private killRabbit(rabbit: Rabbit) {
|
||||||
this.rabbits = this.rabbits.filter((r) => r.id !== rabbit.id);
|
this.rabbits = this.rabbits.filter((r) => r.id !== rabbit.id);
|
||||||
if (this.heldRabbitId === rabbit.id) {
|
if (this.heldRabbitId === rabbit.id) {
|
||||||
this.heldRabbitId = null;
|
this.heldRabbitId = null;
|
||||||
this.rabbitAttackProgress = 0;
|
this.character.resetAttackProgress();
|
||||||
}
|
}
|
||||||
|
this.rabbitsCaughtCount += 1;
|
||||||
this.ejectDrop(rabbit, 'meat');
|
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) {
|
private ejectDrop(origin: Vec2, resource: ResourceType) {
|
||||||
const angle = Math.random() * Math.PI * 2;
|
this.resourceDrops.push(ResourceDrop.eject(origin, resource));
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Advances every resource drop currently in a ballistic flight (falling from a node, or flying
|
/** 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
|
* into the inventory once collected), landing/collecting it once its flight completes. Only
|
||||||
* drops in place and only reassigns the `resourceDrops` array when one is actually removed
|
* reassigns the `resourceDrops` array when one is actually removed (collected) — a landed drop
|
||||||
* (collected) — a landed drop sitting idle, or the array being rebuilt every tick regardless,
|
* sitting idle, or the array being rebuilt every tick regardless, would otherwise force every
|
||||||
* would otherwise force every observer of `resourceDrops` (i.e. the whole map) to re-render
|
* observer of `resourceDrops` (i.e. the whole map) to re-render every animation frame. */
|
||||||
* every animation frame. */
|
|
||||||
private updateDropFlights(deltaTime: number) {
|
private updateDropFlights(deltaTime: number) {
|
||||||
if (this.resourceDrops.length === 0)
|
if (this.resourceDrops.length === 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let collectedIds: Set<string> | null = null;
|
let collectedIds: Set<string> | null = null;
|
||||||
for (const drop of this.resourceDrops) {
|
for (const drop of this.resourceDrops) {
|
||||||
if (drop.phase === 'landed')
|
if (drop.updateFlight(deltaTime) !== 'collected')
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const flight = drop.flight!;
|
(collectedIds ??= new Set()).add(drop.id);
|
||||||
flight.elapsed += deltaTime;
|
if (drop.resource === 'log')
|
||||||
const t = Math.min(1, flight.elapsed / flight.duration);
|
this.inventory.logs += 1;
|
||||||
drop.x = flight.fromX + (flight.toX - flight.fromX) * t;
|
else if (drop.resource === 'twig')
|
||||||
drop.y = flight.fromY + (flight.toY - flight.fromY) * t;
|
this.inventory.twigs += 1;
|
||||||
drop.z = flight.arcHeight * 4 * t * (1 - t);
|
else
|
||||||
|
this.inventory.meat += 1;
|
||||||
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;
|
|
||||||
else if (drop.resource === 'twig')
|
|
||||||
this.inventory.twigs += 1;
|
|
||||||
else
|
|
||||||
this.inventory.meat += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collectedIds)
|
if (collectedIds)
|
||||||
@@ -876,16 +702,7 @@ export class GameStore {
|
|||||||
if (distance > pickupRadius)
|
if (distance > pickupRadius)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
drop.phase = 'collecting';
|
drop.startCollecting(this.character);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,14 +723,17 @@ export class GameStore {
|
|||||||
if (this.inventory.logs > 0) {
|
if (this.inventory.logs > 0) {
|
||||||
this.inventory.logs -= 1;
|
this.inventory.logs -= 1;
|
||||||
this.inventory.coins += this.coinsPerLog;
|
this.inventory.coins += this.coinsPerLog;
|
||||||
|
this.totalCoinsEarned += this.coinsPerLog;
|
||||||
}
|
}
|
||||||
if (this.inventory.twigs > 0) {
|
if (this.inventory.twigs > 0) {
|
||||||
this.inventory.twigs -= 1;
|
this.inventory.twigs -= 1;
|
||||||
this.inventory.coins += this.coinsPerTwig;
|
this.inventory.coins += this.coinsPerTwig;
|
||||||
|
this.totalCoinsEarned += this.coinsPerTwig;
|
||||||
}
|
}
|
||||||
if (this.inventory.meat > 0) {
|
if (this.inventory.meat > 0) {
|
||||||
this.inventory.meat -= 1;
|
this.inventory.meat -= 1;
|
||||||
this.inventory.coins += MEAT_VALUE;
|
this.inventory.coins += this.coinsPerMeat;
|
||||||
|
this.totalCoinsEarned += this.coinsPerMeat;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,3 +38,7 @@ export const ENTITY_MIN_DISTANCE_BETWEEN = 100;
|
|||||||
|
|
||||||
/** Nodes won't spawn within this many px of the map edge. */
|
/** Nodes won't spawn within this many px of the map edge. */
|
||||||
export const ENTITY_EDGE_MARGIN = 70;
|
export const ENTITY_EDGE_MARGIN = 70;
|
||||||
|
|
||||||
|
/** How long the hit-shake animation plays once a hit actually lands (character or rabbit) —
|
||||||
|
* a brief flash at the moment of impact, not for however long the attack itself takes. */
|
||||||
|
export const HIT_FLASH_DURATION = 0.2;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export const NIGHT_HOURS = 24 - NIGHT_START_HOUR + NIGHT_END_HOUR;
|
|||||||
export const DAY_HOURS = 24 - NIGHT_HOURS;
|
export const DAY_HOURS = 24 - NIGHT_HOURS;
|
||||||
|
|
||||||
/** Fixed (non-upgradable) night time-flow speed, in game-hours per real second — derived from the
|
/** Fixed (non-upgradable) night time-flow speed, in game-hours per real second — derived from the
|
||||||
* default 20s day-night cycle with a 10s night. */
|
* default 10s night. */
|
||||||
export const NIGHT_SPEED = NIGHT_HOURS / 10;
|
export const NIGHT_SPEED = NIGHT_HOURS / 20;
|
||||||
|
|
||||||
export function isNightHour(hour: number): boolean {
|
export function isNightHour(hour: number): boolean {
|
||||||
return hour >= NIGHT_START_HOUR || hour < NIGHT_END_HOUR;
|
return hour >= NIGHT_START_HOUR || hour < NIGHT_END_HOUR;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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;
|
||||||
|
/** Total px moved so far this life — an odometer for the death-screen run summary. */
|
||||||
|
distanceTraveled: number;
|
||||||
|
|
||||||
|
private chopProgress = 0;
|
||||||
|
private attackProgress = 0;
|
||||||
|
private regenProgress = 0;
|
||||||
|
|
||||||
|
constructor(x: number, y: number, hp: number, distanceTraveled = 0) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.hp = hp;
|
||||||
|
this.distanceTraveled = distanceTraveled;
|
||||||
|
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;
|
||||||
|
this.distanceTraveled += step;
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
+13
-4
@@ -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';
|
import type { UpgradeId } from './upgrades';
|
||||||
|
|
||||||
const STORAGE_KEY = 'forest-save-v1';
|
const STORAGE_KEY = 'forest-save-v1';
|
||||||
@@ -10,16 +10,25 @@ export interface SaveData {
|
|||||||
/** World state (character/nodes/drops) is optional so older saves without it still load fine —
|
/** 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. */
|
* progress is restored either way, just with a freshly spawned world. */
|
||||||
character?: Vec2;
|
character?: Vec2;
|
||||||
nodes?: ResourceNode[];
|
nodes?: ResourceNodeData[];
|
||||||
resourceDrops?: ResourceDrop[];
|
resourceDrops?: ResourceDropData[];
|
||||||
/** Absent on saves from before rabbits existed, which get a freshly spawned batch instead. */
|
/** 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. */
|
/** Absent on older saves, which are treated as day 1. */
|
||||||
day?: number;
|
day?: number;
|
||||||
/** In-game clock hour (0-24). Absent on older saves, which are treated as noon. */
|
/** In-game clock hour (0-24). Absent on older saves, which are treated as noon. */
|
||||||
hour?: number;
|
hour?: number;
|
||||||
/** Absent on saves from before the character had hp, which default to a full characterMaxHp. */
|
/** Absent on saves from before the character had hp, which default to a full characterMaxHp. */
|
||||||
characterHp?: number;
|
characterHp?: number;
|
||||||
|
/** This life's run-summary stats (shown on the death screen) — absent on older saves or a
|
||||||
|
* fresh game, which default to 0. */
|
||||||
|
distanceTraveled?: number;
|
||||||
|
treesChoppedCount?: number;
|
||||||
|
bushesChoppedCount?: number;
|
||||||
|
rabbitsCaughtCount?: number;
|
||||||
|
/** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes
|
||||||
|
* down when coins are spent on upgrades. */
|
||||||
|
totalCoinsEarned?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidSave(value: unknown): value is SaveData {
|
function isValidSave(value: unknown): value is SaveData {
|
||||||
|
|||||||
+10
-5
@@ -4,10 +4,10 @@ export function rabbitsForDay(day: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** How long a jump or strike action takes to play out. */
|
/** How long a jump or strike action takes to play out. */
|
||||||
export const RABBIT_ACTION_DURATION = 1;
|
export const RABBIT_ACTION_DURATION = 0.5;
|
||||||
|
|
||||||
/** How long a rabbit pauses between actions (this is also when it decides its next one). */
|
/** How long a rabbit pauses between actions (this is also when it decides its next one). */
|
||||||
export const RABBIT_PAUSE_DURATION = 1;
|
export const RABBIT_PAUSE_DURATION = 0.5;
|
||||||
|
|
||||||
/** Max distance covered by a single jump. */
|
/** Max distance covered by a single jump. */
|
||||||
export const RABBIT_JUMP_DISTANCE = 80;
|
export const RABBIT_JUMP_DISTANCE = 80;
|
||||||
@@ -36,8 +36,13 @@ export const RABBIT_STRIKE_DAMAGE = 1;
|
|||||||
* "roughly toward" the character rather than a perfect beeline. */
|
* "roughly toward" the character rather than a perfect beeline. */
|
||||||
export const RABBIT_NIGHT_JUMP_SPREAD_DEGREES = 30;
|
export const RABBIT_NIGHT_JUMP_SPREAD_DEGREES = 30;
|
||||||
|
|
||||||
/** Coins a piece of meat is worth (fixed — unlike logs/twigs, there's no upgrade for it). */
|
|
||||||
export const MEAT_VALUE = 5;
|
|
||||||
|
|
||||||
/** Rabbits (and their strike/hit ranges) are this much bigger during a blood moon. */
|
/** Rabbits (and their strike/hit ranges) are this much bigger during a blood moon. */
|
||||||
export const BLOOD_MOON_SIZE_MULTIPLIER = 2;
|
export const BLOOD_MOON_SIZE_MULTIPLIER = 2;
|
||||||
|
|
||||||
|
/** Rabbits deal this much more strike damage and have this much more max hp during a blood moon —
|
||||||
|
* a bigger jump than their (purely visual/reach) size multiplier above. */
|
||||||
|
export const BLOOD_MOON_COMBAT_MULTIPLIER = 3;
|
||||||
|
|
||||||
|
/** Seconds per hp of rabbit health regen (fixed — unlike the character's own regen, there's no
|
||||||
|
* upgrade for it). */
|
||||||
|
export const RABBIT_HEALTH_REGEN_INTERVAL = 3;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+14
-4
@@ -8,7 +8,9 @@ export type ResourceType = 'log' | 'twig' | 'meat';
|
|||||||
export type NodeResourceType = 'log' | 'twig';
|
export type NodeResourceType = 'log' | 'twig';
|
||||||
export type NodeKind = 'tree' | 'bush';
|
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;
|
id: string;
|
||||||
kind: NodeKind;
|
kind: NodeKind;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -29,7 +31,8 @@ export interface Flight {
|
|||||||
arcHeight: number;
|
arcHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceDrop {
|
/** Plain save-data shape for a ResourceDrop (see entities/ResourceDrop.ts). */
|
||||||
|
export interface ResourceDropData {
|
||||||
id: string;
|
id: string;
|
||||||
resource: ResourceType;
|
resource: ResourceType;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -53,15 +56,22 @@ export interface Inventory {
|
|||||||
* else — including react to the character moving — until the current action finishes. */
|
* else — including react to the character moving — until the current action finishes. */
|
||||||
export type RabbitAction = 'idle' | 'jumping' | 'striking';
|
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;
|
id: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: 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. */
|
* except mid-jump. */
|
||||||
z: number;
|
z: number;
|
||||||
/** Hits remaining before it dies and drops meat. */
|
/** Hits remaining before it dies and drops meat. */
|
||||||
hp: number;
|
hp: number;
|
||||||
|
/** Seconds left to play the hit-shake animation — set only the instant a hit actually lands,
|
||||||
|
* not for the whole time an attack is in progress. */
|
||||||
|
hitFlashTimer: number;
|
||||||
|
/** Accumulates real seconds toward the rabbit's next point of regenerated hp (same
|
||||||
|
* accumulate-then-spend pattern as the character's own health regen). */
|
||||||
|
healthRegenProgress: number;
|
||||||
action: RabbitAction;
|
action: RabbitAction;
|
||||||
/** Seconds elapsed within the current action (or pause, while 'idle'). */
|
/** Seconds elapsed within the current action (or pause, while 'idle'). */
|
||||||
actionTime: number;
|
actionTime: number;
|
||||||
|
|||||||
+33
-10
@@ -3,11 +3,13 @@ export type UpgradeId =
|
|||||||
| 'chopSpeed'
|
| 'chopSpeed'
|
||||||
| 'logValue'
|
| 'logValue'
|
||||||
| 'twigValue'
|
| 'twigValue'
|
||||||
|
| 'meatValue'
|
||||||
| 'pickupRadius'
|
| 'pickupRadius'
|
||||||
| 'coinSpeed'
|
| 'coinSpeed'
|
||||||
| 'daySpeed'
|
| 'dayLength'
|
||||||
| 'maxHealth'
|
| 'maxHealth'
|
||||||
| 'attackDamage';
|
| 'attackDamage'
|
||||||
|
| 'healthRegenSpeed';
|
||||||
|
|
||||||
export interface UpgradeDef {
|
export interface UpgradeDef {
|
||||||
id: UpgradeId;
|
id: UpgradeId;
|
||||||
@@ -16,7 +18,7 @@ export interface UpgradeDef {
|
|||||||
costGrowth: number;
|
costGrowth: number;
|
||||||
/** The upgrade's stat value at a given level (levels start at 1, unbounded above). */
|
/** The upgrade's stat value at a given level (levels start at 1, unbounded above). */
|
||||||
effectAtLevel: (level: number) => number;
|
effectAtLevel: (level: number) => number;
|
||||||
formatEffect: (value: number) => string;
|
formatEffect: (value: number, level: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPGRADE_LIST: UpgradeDef[] = [
|
export const UPGRADE_LIST: UpgradeDef[] = [
|
||||||
@@ -52,6 +54,14 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
|||||||
effectAtLevel: (level) => level * 0.1,
|
effectAtLevel: (level) => level * 0.1,
|
||||||
formatEffect: (v) => `${v.toFixed(2)} монет/ветка`,
|
formatEffect: (v) => `${v.toFixed(2)} монет/ветка`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'meatValue',
|
||||||
|
name: 'Стоимость мяса',
|
||||||
|
baseCost: 9,
|
||||||
|
costGrowth: 1.4,
|
||||||
|
effectAtLevel: (level) => level,
|
||||||
|
formatEffect: (v) => `${v} монет/мясо`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'pickupRadius',
|
id: 'pickupRadius',
|
||||||
name: 'Радиус подбора',
|
name: 'Радиус подбора',
|
||||||
@@ -68,17 +78,17 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
|||||||
// Seconds to convert one unit of a resource into coins — every held resource type converts
|
// Seconds to convert one unit of a resource into coins — every held resource type converts
|
||||||
// on this same cadence at once, so this is the only knob for how fast any of them drain.
|
// on this same cadence at once, so this is the only knob for how fast any of them drain.
|
||||||
effectAtLevel: (level) => 1 / Math.pow(1.15, level - 1),
|
effectAtLevel: (level) => 1 / Math.pow(1.15, level - 1),
|
||||||
formatEffect: (v) => `${v.toFixed(2)} с/ресурс`,
|
formatEffect: (v) => `${v.toFixed(2)} с`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'daySpeed',
|
id: 'dayLength',
|
||||||
name: 'Скорость времени днём',
|
name: 'Длина дня',
|
||||||
baseCost: 6,
|
baseCost: 6,
|
||||||
costGrowth: 1.3,
|
costGrowth: 1.3,
|
||||||
// Game-hours per real second during morning/day/evening (16 game-hours by default spread
|
// 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.
|
// over the default 10s day-phase of the 20s day-night cycle) — night has its own fixed speed.
|
||||||
effectAtLevel: (level) => 1.6 * Math.pow(1.15, level - 1),
|
effectAtLevel: (level) => 16 * Math.pow(1.15, level - 1),
|
||||||
formatEffect: (v) => `${v.toFixed(2)} ч/с`,
|
formatEffect: (v) => `${v.toFixed(0)} с`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'maxHealth',
|
id: 'maxHealth',
|
||||||
@@ -93,8 +103,21 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
|||||||
name: 'Сила удара по кролику',
|
name: 'Сила удара по кролику',
|
||||||
baseCost: 8,
|
baseCost: 8,
|
||||||
costGrowth: 1.4,
|
costGrowth: 1.4,
|
||||||
effectAtLevel: (level) => level,
|
// Damage dealt per hit — the swing cadence itself comes from chopSpeed, shared with
|
||||||
formatEffect: (v) => `${v} урона/с`,
|
// wood-chopping, so this is purely how hard each swing lands. Health is allowed to be
|
||||||
|
// fractional, so this is left unrounded.
|
||||||
|
effectAtLevel: (level) => 0.5 * Math.pow(1.4, level - 1),
|
||||||
|
formatEffect: (v) => `${v.toFixed(1)} здоровья`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'healthRegenSpeed',
|
||||||
|
name: 'Скорость регена здоровья',
|
||||||
|
baseCost: 9,
|
||||||
|
costGrowth: 1.35,
|
||||||
|
// Seconds per regenerated hp — starts at 1 hp per 10s and shrinks (faster regen) with level,
|
||||||
|
// same interval-based shape as coinSpeed.
|
||||||
|
effectAtLevel: (level) => 10 / Math.pow(1.15, level - 1),
|
||||||
|
formatEffect: (v) => `${v.toFixed(1)} с/очко`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
port: 8877,
|
||||||
fs: {
|
fs: {
|
||||||
// The engine package lives outside this project's root (a sibling directory reached
|
// 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.
|
// through a node_modules symlink); allow Vite to serve/watch files from there too.
|
||||||
|
|||||||
Reference in New Issue
Block a user