diff --git a/package-lock.json b/package-lock.json index 0764578..06f2dc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c19156d..22ce876 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/scripts/simulate.ts b/scripts/simulate.ts new file mode 100644 index 0000000..3d4e86b --- /dev/null +++ b/scripts/simulate.ts @@ -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)); diff --git a/src/components/NodeEntity.tsx b/src/components/NodeEntity.tsx index 6bae63b..9586e72 100644 --- a/src/components/NodeEntity.tsx +++ b/src/components/NodeEntity.tsx @@ -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'; diff --git a/src/components/RabbitEntity.tsx b/src/components/RabbitEntity.tsx index 75f3d18..428eb33 100644 --- a/src/components/RabbitEntity.tsx +++ b/src/components/RabbitEntity.tsx @@ -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 ( diff --git a/src/components/ResourceDropEntity.tsx b/src/components/ResourceDropEntity.tsx index 6e21a2d..74cca3c 100644 --- a/src/components/ResourceDropEntity.tsx +++ b/src/components/ResourceDropEntity.tsx @@ -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'; diff --git a/src/game/GameStore.ts b/src/game/GameStore.ts index 4af74cf..4bf1495 100644 --- a/src/game/GameStore.ts +++ b/src/game/GameStore.ts @@ -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 = { moveSpeed: 1, @@ -58,31 +39,23 @@ const DEFAULT_UPGRADE_LEVELS: Record = { 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 = { ...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 | 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); } } diff --git a/src/game/entities/Character.ts b/src/game/entities/Character.ts new file mode 100644 index 0000000..194b407 --- /dev/null +++ b/src/game/entities/Character.ts @@ -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; + } +} diff --git a/src/game/entities/Rabbit.ts b/src/game/entities/Rabbit.ts new file mode 100644 index 0000000..4b1f0d9 --- /dev/null +++ b/src/game/entities/Rabbit.ts @@ -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 & Pick, 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; + } +} diff --git a/src/game/entities/ResourceDrop.ts b/src/game/entities/ResourceDrop.ts new file mode 100644 index 0000000..17a5169 --- /dev/null +++ b/src/game/entities/ResourceDrop.ts @@ -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'; + } +} diff --git a/src/game/entities/ResourceNode.ts b/src/game/entities/ResourceNode.ts new file mode 100644 index 0000000..862788e --- /dev/null +++ b/src/game/entities/ResourceNode.ts @@ -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; + + constructor(kind: NodeKind, x: number, y: number, remaining: Record, 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; + } +} diff --git a/src/game/id.ts b/src/game/id.ts new file mode 100644 index 0000000..8b38568 --- /dev/null +++ b/src/game/id.ts @@ -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)}`; +} diff --git a/src/game/persistence.ts b/src/game/persistence.ts index a899ed7..fd20741 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -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. */ diff --git a/src/game/spawning.ts b/src/game/spawning.ts new file mode 100644 index 0000000..d0749e6 --- /dev/null +++ b/src/game/spawning.ts @@ -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; +} diff --git a/src/game/types.ts b/src/game/types.ts index 945fdad..c2f4158 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -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. */ diff --git a/src/game/upgrades.ts b/src/game/upgrades.ts index 2093e7c..22cab88 100644 --- a/src/game/upgrades.ts +++ b/src/game/upgrades.ts @@ -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',