логика выделена в отдельный набор классов

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