Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0a50aac18 | ||
|
|
7e6b50d1bc | ||
|
|
43f6004fb0 | ||
|
|
4e30c585d1 | ||
|
|
a3f2f4eeef |
@@ -0,0 +1,5 @@
|
||||
# Copy this file to .env.local (already gitignored via *.local) and fill in real values from
|
||||
# your GameAnalytics dashboard. Analytics is skipped entirely (no SDK loaded, no network calls)
|
||||
# if either is left unset.
|
||||
VITE_GAMEANALYTICS_GAME_KEY=
|
||||
VITE_GAMEANALYTICS_GAME_SECRET=
|
||||
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
version.json
|
||||
|
||||
+1
-7
@@ -8,15 +8,9 @@
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>forest</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+104
-99
@@ -9,16 +9,12 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"break_eternity.js": "^2.1.3",
|
||||
"gameanalytics": "^5.0.0",
|
||||
"mobx": "^7.0.3",
|
||||
"mobx-react-lite": "^5.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"pixi.js": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "~6.0.2",
|
||||
@@ -812,6 +808,12 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pixi/colord": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
|
||||
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
|
||||
@@ -1074,6 +1076,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/earcut": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
|
||||
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
@@ -1084,54 +1092,19 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.72",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
|
||||
"integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==",
|
||||
"dev": true,
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
|
||||
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"oxc-transform-react": "^0.145.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rolldown/plugin-babel": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
},
|
||||
"oxc-transform-react": {
|
||||
"optional": true
|
||||
}
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/break_eternity.js": {
|
||||
@@ -1140,13 +1113,6 @@
|
||||
"integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1157,6 +1123,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -1199,6 +1171,12 @@
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1232,6 +1210,33 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gameanalytics": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gameanalytics/-/gameanalytics-5.0.0.tgz",
|
||||
"integrity": "sha512-Hdns5ejXvAXGo8EVlavjNwaKnH+tBHJY83Vf2Cs04FuNPkKZ7HjWC9pB/MO1NnOVdzJFq37dbDGjyfxDC3iNzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gifuct-js": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
|
||||
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-binary-schema-parser": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ismobilejs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
|
||||
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-binary-schema-parser": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
|
||||
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
@@ -1503,20 +1508,6 @@
|
||||
"url": "https://opencollective.com/mobx"
|
||||
}
|
||||
},
|
||||
"node_modules/mobx-react-lite": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-5.0.3.tgz",
|
||||
"integrity": "sha512-8qT/d/8Cg8ljt+heKhoFHNoqgedyeSGzdTixT0lgbFMMFlWfHUcXHZ8UD63eQi3ELjkxaH1VU07lXWhRwM0cVA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mobx"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mobx": "^7.0.0",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
@@ -1585,6 +1576,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
|
||||
"integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1605,6 +1602,32 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pixi.js": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.1.tgz",
|
||||
"integrity": "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"examples",
|
||||
"playground"
|
||||
],
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6",
|
||||
"@types/earcut": "^3.0.0",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"earcut": "^3.0.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"ismobilejs": "^1.1.1",
|
||||
"parse-svg-path": "^0.2.0",
|
||||
"tiny-lru": "^11.4.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/pixijs"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
@@ -1634,27 +1657,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
|
||||
@@ -1689,12 +1691,6 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1705,6 +1701,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-lru": {
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
|
||||
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
+4
-7
@@ -9,20 +9,17 @@
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"sim": "tsx scripts/simulate.ts",
|
||||
"play": "tsx scripts/play.ts"
|
||||
"play": "tsx scripts/play.ts",
|
||||
"version": "node -e \"const{execSync}=require('child_process');const{writeFileSync}=require('fs');writeFileSync('version.json',JSON.stringify(execSync('git describe --tags --always --dirty').toString().trim())+'\\n');\""
|
||||
},
|
||||
"dependencies": {
|
||||
"break_eternity.js": "^2.1.3",
|
||||
"gameanalytics": "^5.0.0",
|
||||
"mobx": "^7.0.3",
|
||||
"mobx-react-lite": "^5.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"pixi.js": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+11
-4
@@ -16,6 +16,8 @@
|
||||
* Run with: npx tsx scripts/play.ts [tickSeconds]
|
||||
*/
|
||||
import readline from 'node:readline';
|
||||
import { Analytics, makeAnalyticsGameInfo } from '../src/analytics';
|
||||
import { ConsoleAnalyticsTransport } from '../src/analytics/ConsoleAnalyticsTransport';
|
||||
import { bushesForDay, treesForDay } from '../src/game/constants';
|
||||
import { formatClock } from '../src/game/daynight';
|
||||
import { Rabbit } from '../src/game/entities/Rabbit';
|
||||
@@ -23,17 +25,22 @@ import { ResourceNode } from '../src/game/entities/ResourceNode';
|
||||
import { GameStore } from '../src/game/GameStore';
|
||||
import { costForNextLevel, UPGRADE_LIST, type UpgradeId } from '../src/game/upgrades';
|
||||
|
||||
const MAP_WIDTH = 1200;
|
||||
const MAP_HEIGHT = 800;
|
||||
const MAP_WIDTH = 400;
|
||||
const MAP_HEIGHT = 260;
|
||||
/** How many simulated seconds pass per tick — fixed, independent of how fast ticks actually run. */
|
||||
const TICK_SECONDS = Number(process.argv[2] ?? 0.1);
|
||||
/** Ticks per event-loop turn between yields — batching amortizes the yield's overhead while
|
||||
* still keeping Ctrl+C/output responsive within about one batch's worth of latency. */
|
||||
const TICKS_PER_BATCH = 1000;
|
||||
/** How far past the nearest rabbit to aim while fleeing it. */
|
||||
const FLEE_DISTANCE = 400;
|
||||
const FLEE_DISTANCE = 133;
|
||||
|
||||
const store = new GameStore();
|
||||
// Logs every analytics call to the console (see ConsoleAnalyticsTransport) instead of sending it
|
||||
// anywhere — this is what actually lets `npm run play` be used to verify analytics behavior.
|
||||
let store: GameStore;
|
||||
const analytics = new Analytics(new ConsoleAnalyticsTransport(), () => makeAnalyticsGameInfo(store));
|
||||
analytics.start();
|
||||
store = new GameStore(analytics);
|
||||
store.initialize(MAP_WIDTH, MAP_HEIGHT);
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/** 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));
|
||||
+49
-150
@@ -52,6 +52,7 @@
|
||||
}
|
||||
|
||||
.main-menu {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -61,6 +62,15 @@
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.main-menu__version {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 12px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
color: var(--panel-border);
|
||||
}
|
||||
|
||||
.main-menu__title {
|
||||
font-size: 32px;
|
||||
margin: 0;
|
||||
@@ -75,6 +85,12 @@
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
/* Temporary — see MainMenu.ts/DebugCharacterPreview.ts. */
|
||||
.main-menu__debug-preview {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
@@ -95,6 +111,28 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-seed-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-seed-input {
|
||||
padding: 14px 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 4px solid var(--panel-border);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-seed-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.main-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -199,132 +237,9 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.health-bar {
|
||||
position: absolute;
|
||||
width: 28px;
|
||||
height: 5px;
|
||||
transform: translate(-50%, -100%);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid #1b1611;
|
||||
z-index: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.health-bar__fill {
|
||||
height: 100%;
|
||||
background: #4caf50;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.entity {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.entity.log-shadow {
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
margin: 8px 0px 0px 0px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.twig-shadow {
|
||||
width: 18px;
|
||||
height: 9px;
|
||||
margin: 7px 0px 0px 0px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.tree-shadow {
|
||||
width: 64px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
margin: 26px 0px 0px -11px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.bush-shadow {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
margin: 16px 0px 0px -6px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.character-shadow {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin: 32px 0px 0px 0px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.rabbit-shadow {
|
||||
width: 18px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
margin: 32px 10px 0px 0px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.meat-shadow {
|
||||
width: 18px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
margin: 7px 0px 0px 0px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.tree {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
margin: -12px;
|
||||
}
|
||||
|
||||
.entity.bush {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
margin: -8px;
|
||||
}
|
||||
|
||||
.entity.rabbit {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
margin: -6px;
|
||||
}
|
||||
|
||||
.entity.chopping svg {
|
||||
animation: chop-shake 0.12s infinite;
|
||||
}
|
||||
|
||||
.entity.character {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.entity.character.character--walking svg {
|
||||
animation: walk-bob 0.3s infinite steps(2);
|
||||
}
|
||||
|
||||
/* Same specificity as the walk-bob rule above (and placed after it), so the hit-shake still
|
||||
* shows on the brief flash even if the character happens to be walking at that exact moment. */
|
||||
.entity.character.chopping svg {
|
||||
animation: chop-shake 0.12s infinite;
|
||||
}
|
||||
|
||||
.day-night-overlay {
|
||||
/* Wraps the blood-moon banner and death screen, both absolutely positioned over the game canvas
|
||||
* (see GameOverlays.ts) — pointer-events is re-enabled on .game-over-overlay below for its button. */
|
||||
.game-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
@@ -356,6 +271,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Author `display: flex` below otherwise wins the cascade over the UA stylesheet's
|
||||
* `[hidden] { display: none }` (equal specificity, author beats user-agent) — without this, toggling
|
||||
* the `hidden` IDL property (see GameOverlays.ts) wouldn't actually hide the element. */
|
||||
.game-over-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-over-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -366,6 +288,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-over-overlay__message {
|
||||
@@ -385,27 +308,3 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@keyframes chop-shake {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translate(-2px, 1px) rotate(-6deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(1px, -2px) rotate(5deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-1px, 1px) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes walk-bob {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import GameMap from './components/GameMap';
|
||||
import MainMenu from './components/MainMenu';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Topbar from './components/Topbar';
|
||||
import { GameStore } from './game/GameStore';
|
||||
import { clearSave } from './game/persistence';
|
||||
import './App.css';
|
||||
|
||||
type Screen = 'menu' | 'game';
|
||||
|
||||
function App() {
|
||||
const [store] = useState(() => new GameStore());
|
||||
// Always starts on the menu, regardless of whether a save exists — loading straight into a
|
||||
// resumed game on every page (re)load isn't what was asked for.
|
||||
const [screen, setScreen] = useState<Screen>('menu');
|
||||
|
||||
function handleNewGame() {
|
||||
clearSave();
|
||||
setScreen('game');
|
||||
}
|
||||
|
||||
function handleExitToMenu() {
|
||||
store.save();
|
||||
setScreen('menu');
|
||||
}
|
||||
|
||||
// Called after a death overlay is acknowledged: the store already wiped the save itself the
|
||||
// moment the character was caught, so this just returns to the menu without saving over it.
|
||||
function handleDeath() {
|
||||
setScreen('menu');
|
||||
}
|
||||
|
||||
if (screen === 'menu') {
|
||||
return <MainMenu onNewGame={handleNewGame} onContinue={() => setScreen('game')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Topbar store={store} onExitToMenu={handleExitToMenu} />
|
||||
<div className="main-row">
|
||||
<Sidebar store={store} />
|
||||
<GameMap store={store} onDeath={handleDeath} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,9 @@
|
||||
export abstract class AnalyticsTransport {
|
||||
abstract init(data: { version: string, resourceTypes: string[], resourceCurrencies: string[] }): void;
|
||||
|
||||
abstract event(data: { event: string, value?: number, [x: string]: unknown }): void;
|
||||
abstract ui(data: { event: string, value?: number, [x: string]: unknown }): void;
|
||||
abstract progress(data: { world: string | number, level: string | number, sublevel?: string | number, kind: 'start' | 'complete' | 'fail', score?: number, [x: string]: unknown }): void;
|
||||
abstract transaction(data: { resourceType: string, resourceId: string, amount: number, action: 'gain' | 'spend', withCurrency: string, [x: string]: unknown }): void;
|
||||
abstract error(data: { severity: 'error', message: string, [x: string]: unknown }): void;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AnalyticsTransport } from './AnalyticsTransport';
|
||||
|
||||
/** Logs every analytics call to the console instead of sending it anywhere — lets `npm run play`
|
||||
* (or any other manual/headless run) show exactly what the game engine would have tracked,
|
||||
* without needing real GameAnalytics credentials or a network call. */
|
||||
export class ConsoleAnalyticsTransport extends AnalyticsTransport {
|
||||
init(data: { version: string, resourceTypes: string[], resourceCurrencies: string[] }): void {
|
||||
console.log('[analytics] init', data);
|
||||
}
|
||||
|
||||
event(data: { event: string, value?: number, [x: string]: unknown }): void {
|
||||
console.log('[analytics] event', data);
|
||||
}
|
||||
|
||||
ui(data: { event: string, value?: number, [x: string]: unknown }): void {
|
||||
console.log('[analytics] ui', data);
|
||||
}
|
||||
|
||||
progress(data: { world: string | number, level: string | number, sublevel?: string | number, kind: 'start' | 'complete' | 'fail', score?: number, [x: string]: unknown }): void {
|
||||
console.log('[analytics] progress', data);
|
||||
}
|
||||
|
||||
transaction(data: { resourceType: string, resourceId: string, amount: number, action: 'gain' | 'spend', withCurrency: string, [x: string]: unknown }): void {
|
||||
console.log('[analytics] transaction', data);
|
||||
}
|
||||
|
||||
error(data: { severity: 'error', message: string, [x: string]: unknown }): void {
|
||||
console.log('[analytics] error', data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { gameanalytics } from 'gameanalytics';
|
||||
|
||||
import { AnalyticsTransport } from './AnalyticsTransport';
|
||||
|
||||
// gameanalytics' type declarations don't expose these enums even though they exist at runtime,
|
||||
// so they're mirrored here from the SDK's public values (see GameAnalytics.d.ts EGA* enums).
|
||||
const EGAErrorSeverity = { Error: 4 } as const;
|
||||
const EGAProgressionStatus = { Start: 1, Complete: 2, Fail: 3 } as const;
|
||||
const EGAResourceFlowType = { Source: 1, Sink: 2 } as const;
|
||||
|
||||
export class GameAnalyticsTransport extends AnalyticsTransport {
|
||||
private readonly gameKey: string;
|
||||
private readonly gameSecret: string;
|
||||
|
||||
constructor(gameKey: string, gameSecret: string) {
|
||||
super();
|
||||
this.gameKey = gameKey;
|
||||
this.gameSecret = gameSecret;
|
||||
}
|
||||
|
||||
init(data: {
|
||||
version: string,
|
||||
resourceTypes: string[],
|
||||
resourceCurrencies: string[],
|
||||
}) {
|
||||
if (import.meta.env.DEV) {
|
||||
gameanalytics.GameAnalytics.setEnabledInfoLog(true);
|
||||
gameanalytics.GameAnalytics.setEnabledEventSubmission(true);
|
||||
}
|
||||
gameanalytics.GameAnalytics.configureBuild(data.version);
|
||||
gameanalytics.GameAnalytics.configureAvailableResourceCurrencies(data.resourceCurrencies);
|
||||
gameanalytics.GameAnalytics.configureAvailableResourceItemTypes(data.resourceTypes);
|
||||
// gameanalytics.GameAnalytics.configureAvailableCustomDimensions01(["easy", "normal", "hard"]);
|
||||
gameanalytics.GameAnalytics.initialize(this.gameKey, this.gameSecret);
|
||||
}
|
||||
|
||||
ui(data: { event: string, value?: number, [x: string]: unknown }): void {
|
||||
this.event({ ...data, event: 'UI:' + data.event });
|
||||
}
|
||||
|
||||
progress(data: { world: string | number, level: string | number, sublevel?: string | number, kind: 'start' | 'complete' | 'fail', score?: number, [x: string]: unknown }): void {
|
||||
const { kind, world, level, sublevel, score, ...params } = data;
|
||||
gameanalytics.GameAnalytics.addProgressionEvent(
|
||||
kind === 'start' ? EGAProgressionStatus.Start : (kind === 'complete' ? EGAProgressionStatus.Complete : EGAProgressionStatus.Fail),
|
||||
'' + world,
|
||||
'' + level,
|
||||
'' + sublevel,
|
||||
score,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
transaction(data: { resourceType: string, resourceId: string, amount: number, action: 'gain' | 'spend', withCurrency: string, [x: string]: unknown }): void {
|
||||
const { resourceType, resourceId, amount, action, withCurrency, ...params } = data;
|
||||
gameanalytics.GameAnalytics.addResourceEvent(
|
||||
action === 'gain' ? EGAResourceFlowType.Source : EGAResourceFlowType.Sink,
|
||||
withCurrency,
|
||||
amount,
|
||||
resourceType,
|
||||
resourceId,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
event(data: { event: string, value?: number, [x: string]: unknown }): void {
|
||||
const { event, value, ...params } = data;
|
||||
gameanalytics.GameAnalytics.addDesignEvent('UI:' + event, value, params);
|
||||
}
|
||||
|
||||
error(data: { severity: 'error', message: string, [x: string]: unknown }): void {
|
||||
const { severity, message, ...params } = data;
|
||||
gameanalytics.GameAnalytics.addErrorEvent(EGAErrorSeverity.Error, message.slice(0, 8192), params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AnalyticsTransport } from './AnalyticsTransport';
|
||||
|
||||
export class NullAnalyticsTransport extends AnalyticsTransport {
|
||||
init(_data: { version: string, resourceTypes: string[], resourceCurrencies: string[] }): void {};
|
||||
event(_data: { event: string, value?: number, [x: string]: unknown }): void { }
|
||||
ui(_data: { event: string, value?: number, [x: string]: unknown }): void { }
|
||||
progress(_data: { world: string | number, level: string | number, sublevel?: string | number, kind: 'start' | 'complete' | 'fail', score?: number, [x: string]: unknown }): void { }
|
||||
transaction(_data: { resourceType: string, resourceId: string, amount: number, action: 'gain' | 'spend', withCurrency: string, [x: string]: unknown }): void { }
|
||||
error(_data: { severity: 'error', message: string, [x: string]: unknown }): void { }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { AnalyticsTransport } from "./AnalyticsTransport";
|
||||
|
||||
import version from "../../version.json";
|
||||
import type { GameStore } from "../game/GameStore";
|
||||
|
||||
export type RuntimeGameInfo = {
|
||||
playTime: number; // seconds
|
||||
day: number;
|
||||
dayTime: number; // hours
|
||||
}
|
||||
|
||||
export function makeAnalyticsGameInfo(store: GameStore): RuntimeGameInfo {
|
||||
return {
|
||||
playTime: store.playTimeSeconds,
|
||||
day: store.day,
|
||||
dayTime: store.hour,
|
||||
};
|
||||
}
|
||||
|
||||
export class Analytics {
|
||||
private readonly transport: AnalyticsTransport;
|
||||
private gameProfider: () => RuntimeGameInfo;
|
||||
|
||||
constructor(transport: AnalyticsTransport, game: () => RuntimeGameInfo) {
|
||||
this.transport = transport;
|
||||
this.gameProfider = game;
|
||||
}
|
||||
|
||||
private get game(): RuntimeGameInfo {
|
||||
return this.gameProfider();
|
||||
}
|
||||
|
||||
start() {
|
||||
this.transport.init({
|
||||
version,
|
||||
resourceTypes: ['upgrade'],
|
||||
resourceCurrencies: ['coin'],
|
||||
});
|
||||
}
|
||||
|
||||
buyUpgrade(updateId: string, level: number) {
|
||||
this.transport.transaction({
|
||||
resourceType: 'progress',
|
||||
resourceId: updateId,
|
||||
action: 'gain',
|
||||
withCurrency: 'coin',
|
||||
amount: level,
|
||||
game: this.game,
|
||||
});
|
||||
}
|
||||
|
||||
menu() {
|
||||
this.ui(`menu:start`);
|
||||
}
|
||||
|
||||
button(id: string, params?: Record<string, unknown>) {
|
||||
this.ui(`button:${id}:click`, params);
|
||||
}
|
||||
|
||||
private ui(id: string, params?: Record<string, unknown>, value?: number) {
|
||||
this.transport.ui({
|
||||
event: id,
|
||||
value,
|
||||
game: this.game,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
startLevel(level: number) {
|
||||
this.transport.progress({
|
||||
kind: 'start',
|
||||
world: 'main',
|
||||
level,
|
||||
game: this.game,
|
||||
});
|
||||
}
|
||||
|
||||
completeLevel(level: number, score: number) {
|
||||
this.transport.progress({
|
||||
kind: 'complete',
|
||||
world: 'main',
|
||||
level,
|
||||
score,
|
||||
game: this.game,
|
||||
});
|
||||
}
|
||||
|
||||
death(reason: string) {
|
||||
this.transport.progress({
|
||||
kind: 'fail',
|
||||
world: 'main',
|
||||
level: this.game.day,
|
||||
score: 0,
|
||||
game: this.game,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import '../App.css';
|
||||
import { Analytics, makeAnalyticsGameInfo } from '../analytics';
|
||||
import type { AnalyticsTransport } from '../analytics/AnalyticsTransport';
|
||||
import { GameAnalyticsTransport } from '../analytics/GameAnalyticsTransport';
|
||||
import { NullAnalyticsTransport } from '../analytics/NullAnalyticsTransport';
|
||||
import { GameStore } from '../game/GameStore';
|
||||
import { clearSave, hasSave } from '../game/persistence';
|
||||
import { GameScreen } from './GameScreen';
|
||||
import { createMainMenu } from './MainMenu';
|
||||
import { createSettingsScreen } from './SettingsScreen';
|
||||
|
||||
/** Top-level screen controller (menu <-> game) — the plain-TS replacement for App.tsx. Owns the
|
||||
* GameStore/Analytics singletons for the page's whole lifetime, same as the original module-level
|
||||
* singletons (no React StrictMode double-invocation to guard against here, since nothing
|
||||
* constructs this more than once — see main.ts). */
|
||||
export class App {
|
||||
private readonly root: HTMLElement;
|
||||
private readonly store: GameStore;
|
||||
private readonly analytics: Analytics;
|
||||
private gameScreen: GameScreen | null = null;
|
||||
|
||||
constructor(root: HTMLElement) {
|
||||
this.root = root;
|
||||
|
||||
const gaGameKey = import.meta.env.VITE_GAMEANALYTICS_GAME_KEY;
|
||||
const gaGameSecret = import.meta.env.VITE_GAMEANALYTICS_GAME_SECRET;
|
||||
let transport: AnalyticsTransport;
|
||||
if (!gaGameKey || !gaGameSecret) {
|
||||
console.warn('VITE_GAMEANALYTICS_GAME_KEY/VITE_GAMEANALYTICS_GAME_SECRET not set — analytics disabled (see .env.example).');
|
||||
transport = new NullAnalyticsTransport();
|
||||
} else {
|
||||
transport = new GameAnalyticsTransport(gaGameKey, gaGameSecret);
|
||||
}
|
||||
|
||||
this.analytics = new Analytics(transport, () => makeAnalyticsGameInfo(this.store));
|
||||
this.analytics.start();
|
||||
this.store = new GameStore(this.analytics);
|
||||
|
||||
// Always starts on the menu, regardless of whether a save exists — loading straight into a
|
||||
// resumed game on every page (re)load isn't what was asked for.
|
||||
this.showMenu();
|
||||
}
|
||||
|
||||
private showMenu(): void {
|
||||
this.teardownGameScreen();
|
||||
const menu = createMainMenu(
|
||||
this.analytics,
|
||||
() => this.handleNewGame(),
|
||||
() => this.handleContinue(),
|
||||
() => this.showSettings(),
|
||||
);
|
||||
this.root.replaceChildren(menu);
|
||||
}
|
||||
|
||||
private showSettings(): void {
|
||||
const settings = createSettingsScreen(
|
||||
this.store.seed,
|
||||
() => this.showMenu(),
|
||||
(seed) => this.handleNewGame(seed),
|
||||
);
|
||||
this.root.replaceChildren(settings);
|
||||
}
|
||||
|
||||
private showGame(): void {
|
||||
this.gameScreen = new GameScreen(
|
||||
this.store,
|
||||
() => this.handleExitToMenu(),
|
||||
() => this.handleDeath(),
|
||||
);
|
||||
this.root.replaceChildren(this.gameScreen.el);
|
||||
// Only now is `el` actually attached to the document — GameScreen's canvas sizes itself from
|
||||
// the map div's clientWidth/Height, which reads 0 on a still-detached element.
|
||||
this.gameScreen.mount();
|
||||
}
|
||||
|
||||
/** Starts a fresh game — a freshly random seed by default, or an explicit one when called from
|
||||
* Settings' seed field (which also acts as "restart the game with this seed"). */
|
||||
private handleNewGame(seed?: number): void {
|
||||
// A resumable save existing means there's a live (not-dead) game being abandoned by starting
|
||||
// fresh instead of continuing it — record that as the last level's failure. A death already
|
||||
// records its own failure at the moment it happens (see GameStore.landRabbitStrike) and clears
|
||||
// the save there too, so this naturally doesn't double-fire right after a death.
|
||||
if (hasSave())
|
||||
this.analytics.death('restarted');
|
||||
clearSave();
|
||||
if (seed !== undefined)
|
||||
this.store.setNextSeed(seed);
|
||||
this.showGame();
|
||||
}
|
||||
|
||||
private handleContinue(): void {
|
||||
this.showGame();
|
||||
}
|
||||
|
||||
private handleExitToMenu(): void {
|
||||
this.store.save();
|
||||
this.showMenu();
|
||||
}
|
||||
|
||||
// Called after a death overlay is acknowledged: the store already wiped the save itself the
|
||||
// moment the character was caught, so this just returns to the menu without saving over it.
|
||||
private handleDeath(): void {
|
||||
this.showMenu();
|
||||
}
|
||||
|
||||
private teardownGameScreen(): void {
|
||||
this.gameScreen?.destroy();
|
||||
this.gameScreen = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { autorun } from 'mobx';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import type { Mounted } from './Topbar';
|
||||
|
||||
/** Russian plural agreement for "день" (day) after a count. */
|
||||
function dayWord(n: number): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11)
|
||||
return 'день';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
|
||||
return 'дня';
|
||||
return 'дней';
|
||||
}
|
||||
|
||||
/** Builds the "КРОВАВАЯ ЛУНА!" banner and the death screen, both absolutely positioned over the
|
||||
* game map (siblings of the Pixi canvas — see App.ts) — the DOM-side replacement for the
|
||||
* corresponding pieces of GameMap.tsx. Both stay hidden/empty until the store says otherwise, kept
|
||||
* in sync via a single mobx autorun. */
|
||||
export function createGameOverlays(store: GameStore, onDeath: () => void): Mounted {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'game-overlays';
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'blood-moon-banner';
|
||||
banner.textContent = 'КРОВАВАЯ ЛУНА!';
|
||||
banner.hidden = true;
|
||||
|
||||
const gameOver = document.createElement('div');
|
||||
gameOver.className = 'game-over-overlay';
|
||||
gameOver.hidden = true;
|
||||
|
||||
const message = document.createElement('div');
|
||||
message.className = 'game-over-overlay__message';
|
||||
|
||||
const stats = document.createElement('div');
|
||||
stats.className = 'game-over-overlay__stats';
|
||||
const distance = document.createElement('div');
|
||||
const chopped = document.createElement('div');
|
||||
const caught = document.createElement('div');
|
||||
const coins = document.createElement('div');
|
||||
stats.append(distance, chopped, caught, coins);
|
||||
|
||||
const menuBtn = document.createElement('button');
|
||||
menuBtn.type = 'button';
|
||||
menuBtn.className = 'menu-btn';
|
||||
// The whole map underneath (including this overlay) has a pointerdown handler that captures the
|
||||
// pointer and drives character movement — without stopping it here, that handler fires first on
|
||||
// bubble-up and swallows the tap before it becomes a click (see GameCanvas).
|
||||
menuBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
menuBtn.addEventListener('click', onDeath);
|
||||
menuBtn.textContent = 'В меню';
|
||||
|
||||
gameOver.append(message, stats, menuBtn);
|
||||
root.append(banner, gameOver);
|
||||
|
||||
const dispose = autorun(() => {
|
||||
banner.hidden = !store.bloodMoonBannerVisible;
|
||||
|
||||
gameOver.hidden = !store.isDead;
|
||||
if (!store.isDead)
|
||||
return;
|
||||
message.textContent = `Вы прожили ${store.day} ${dayWord(store.day)}, но вас поймали кролики`;
|
||||
distance.textContent = `Пройдено: ${Math.round(store.character.distanceTraveled)} px`;
|
||||
chopped.textContent = `Срублено: ${store.treesChoppedCount} деревьев, ${store.bushesChoppedCount} кустов`;
|
||||
caught.textContent = `Поймано кроликов: ${store.rabbitsCaughtCount}`;
|
||||
coins.textContent = `Заработано монет: ${store.totalCoinsEarned.toFixed(1)}`;
|
||||
});
|
||||
|
||||
return { el: root, dispose };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { GameCanvas } from '../pixi/GameCanvas';
|
||||
import { createGameOverlays } from './GameOverlays';
|
||||
import { createSidebar } from './Sidebar';
|
||||
import { createTopbar } from './Topbar';
|
||||
|
||||
/** Builds the "playing" screen (topbar, sidebar, game map) and wires up the Pixi canvas — the
|
||||
* DOM-side replacement for App.tsx's game-screen JSX plus GameMap.tsx's own structure. */
|
||||
export class GameScreen {
|
||||
readonly el: HTMLElement;
|
||||
|
||||
private readonly canvas = new GameCanvas();
|
||||
private readonly topbar: ReturnType<typeof createTopbar>;
|
||||
private readonly sidebar: ReturnType<typeof createSidebar>;
|
||||
private readonly overlays: ReturnType<typeof createGameOverlays>;
|
||||
private readonly mapEl: HTMLElement;
|
||||
private readonly store: GameStore;
|
||||
|
||||
constructor(store: GameStore, onExitToMenu: () => void, onDeath: () => void) {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'app';
|
||||
|
||||
this.topbar = createTopbar(store, onExitToMenu);
|
||||
|
||||
const mainRow = document.createElement('div');
|
||||
mainRow.className = 'main-row';
|
||||
|
||||
this.sidebar = createSidebar(store);
|
||||
|
||||
const mapEl = document.createElement('div');
|
||||
mapEl.className = 'game-map';
|
||||
this.overlays = createGameOverlays(store, onDeath);
|
||||
mapEl.appendChild(this.overlays.el);
|
||||
|
||||
mainRow.append(this.sidebar.el, mapEl);
|
||||
this.el.append(this.topbar.el, mainRow);
|
||||
|
||||
this.mapEl = mapEl;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
/** Starts the Pixi canvas. Must be called only after `el` is attached to the document — the
|
||||
* canvas is sized from the map div's clientWidth/Height, which is 0 on a detached element, so
|
||||
* calling this from the constructor (before App.ts inserts `el` into the page) would silently
|
||||
* measure a zero-size box and never create the canvas at all. */
|
||||
mount(): void {
|
||||
void this.canvas.mount(this.mapEl, this.store);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.canvas.destroy();
|
||||
this.topbar.dispose();
|
||||
this.sidebar.dispose();
|
||||
this.overlays.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Analytics } from '../analytics';
|
||||
import { hasSave } from '../game/persistence';
|
||||
import { mountDebugCharacterPreview } from '../pixi/DebugCharacterPreview';
|
||||
|
||||
/** Builds the main menu screen — the DOM-side replacement for MainMenu.tsx. Fires analytics.menu()
|
||||
* immediately (once per call), matching the original "once per mount" behavior since a fresh
|
||||
* element is built every time the player returns to the menu (see App.ts). */
|
||||
export function createMainMenu(
|
||||
analytics: Analytics,
|
||||
onNewGame: () => void,
|
||||
onContinue: () => void,
|
||||
onSettings: () => void,
|
||||
): HTMLElement {
|
||||
analytics.menu();
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'main-menu';
|
||||
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'main-menu__title';
|
||||
title.textContent = 'Forest';
|
||||
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'main-menu__buttons';
|
||||
|
||||
const newGameBtn = document.createElement('button');
|
||||
newGameBtn.type = 'button';
|
||||
newGameBtn.className = 'menu-btn';
|
||||
newGameBtn.textContent = 'Новая игра';
|
||||
newGameBtn.addEventListener('click', () => {
|
||||
analytics.button('new_game');
|
||||
onNewGame();
|
||||
});
|
||||
|
||||
const continueBtn = document.createElement('button');
|
||||
continueBtn.type = 'button';
|
||||
continueBtn.className = 'menu-btn';
|
||||
continueBtn.textContent = 'Продолжить';
|
||||
continueBtn.disabled = !hasSave();
|
||||
continueBtn.addEventListener('click', () => {
|
||||
analytics.button('continue');
|
||||
onContinue();
|
||||
});
|
||||
|
||||
const settingsBtn = document.createElement('button');
|
||||
settingsBtn.type = 'button';
|
||||
settingsBtn.className = 'menu-btn';
|
||||
settingsBtn.textContent = 'Настройки';
|
||||
settingsBtn.addEventListener('click', () => {
|
||||
analytics.button('settings');
|
||||
onSettings();
|
||||
});
|
||||
|
||||
buttons.append(newGameBtn, continueBtn, settingsBtn);
|
||||
|
||||
// Temporary: a pixel-grid sanity check, rendered through the exact same pipeline as the game
|
||||
// world — see DebugCharacterPreview.ts's doc comment. Remove this block (and the container div)
|
||||
// once no longer needed.
|
||||
const debugPreview = document.createElement('div');
|
||||
debugPreview.className = 'main-menu__debug-preview';
|
||||
// The preview's canvas sizes itself from this div's clientWidth/Height once actually laid out —
|
||||
// 0 on a still-detached element — so mounting has to wait until after `root` is attached to the
|
||||
// document (see App.ts, which inserts the element returned here synchronously right after this
|
||||
// function returns; requestAnimationFrame's callback always runs after that).
|
||||
requestAnimationFrame(() => {
|
||||
void mountDebugCharacterPreview(debugPreview);
|
||||
});
|
||||
|
||||
const version = document.createElement('div');
|
||||
version.className = 'main-menu__version';
|
||||
version.textContent = `v${__APP_VERSION__}`;
|
||||
|
||||
root.append(title, buttons, debugPreview, version);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { getRenderSettings, setRenderSettings } from '../pixi/renderSettings';
|
||||
|
||||
/** Builds a toggle button that shows its current on/off state in its own label and flips it on
|
||||
* click — the DOM-side equivalent of a checkbox, styled like the rest of the menu's buttons. */
|
||||
function createToggle(label: string, initial: boolean, onChange: (value: boolean) => void): HTMLButtonElement {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'menu-btn';
|
||||
let value = initial;
|
||||
|
||||
function render(): void {
|
||||
btn.textContent = `${label}: ${value ? 'вкл' : 'выкл'}`;
|
||||
}
|
||||
render();
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
value = !value;
|
||||
render();
|
||||
onChange(value);
|
||||
});
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** Builds the settings screen — rendering-diagnostics toggles that take effect the next time a
|
||||
* game is (re)started (GameCanvas reads them once, on mount), plus a seed field, reachable from
|
||||
* the main menu.
|
||||
* @param currentSeed The running game's current PRNG seed (see GameStore.seed) — 0 if no game has
|
||||
* started yet this page load. Shown as the seed field's starting value.
|
||||
* @param onStartWithSeed Called with a new seed the instant the seed field's value is committed
|
||||
* (blur or Enter) — starts (or restarts) a game seeded from it, discarding any existing save, the
|
||||
* same way "Новая игра" does. */
|
||||
export function createSettingsScreen(currentSeed: number, onBack: () => void, onStartWithSeed: (seed: number) => void): HTMLElement {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'main-menu';
|
||||
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'main-menu__title';
|
||||
title.textContent = 'Настройки';
|
||||
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'main-menu__buttons';
|
||||
|
||||
const settings = getRenderSettings();
|
||||
|
||||
const antialiasingBtn = createToggle('Антиалиасинг экрана', settings.antialiasing, (value) => {
|
||||
setRenderSettings({ antialiasing: value });
|
||||
});
|
||||
|
||||
const noScaleBtn = createToggle('Рендер без масштабирования (k=1)', settings.noScale, (value) => {
|
||||
setRenderSettings({ noScale: value });
|
||||
});
|
||||
|
||||
const seedLabel = document.createElement('label');
|
||||
seedLabel.className = 'settings-seed-label';
|
||||
seedLabel.textContent = 'Сид игры (новая игра при изменении)';
|
||||
|
||||
const seedInput = document.createElement('input');
|
||||
seedInput.type = 'number';
|
||||
seedInput.className = 'settings-seed-input';
|
||||
seedInput.min = '0';
|
||||
seedInput.max = '4294967295';
|
||||
seedInput.step = '1';
|
||||
seedInput.value = String(currentSeed);
|
||||
seedInput.addEventListener('change', () => {
|
||||
const parsed = Math.floor(Number(seedInput.value));
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
seedInput.value = String(currentSeed);
|
||||
return;
|
||||
}
|
||||
onStartWithSeed(parsed >>> 0);
|
||||
});
|
||||
seedLabel.appendChild(seedInput);
|
||||
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.type = 'button';
|
||||
backBtn.className = 'menu-btn';
|
||||
backBtn.textContent = 'Назад';
|
||||
backBtn.addEventListener('click', onBack);
|
||||
|
||||
buttons.append(antialiasingBtn, noScaleBtn, seedLabel, backBtn);
|
||||
root.append(title, buttons);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { autorun } from 'mobx';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { COIN_SPRITE } from '../game/sprites';
|
||||
import { costForNextLevel, UPGRADE_LIST } from '../game/upgrades';
|
||||
import { pixelIcon } from './pixelIcon';
|
||||
import type { Mounted } from './Topbar';
|
||||
|
||||
/** Builds the sidebar's upgrade list, keeping each button's level/effects/cost/disabled state in
|
||||
* sync via a single mobx autorun — the DOM-side replacement for Sidebar.tsx. */
|
||||
export function createSidebar(store: GameStore): Mounted {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'sidebar';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'sidebar__title';
|
||||
title.textContent = 'Апгрейды';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'upgrade-list';
|
||||
|
||||
const rows = UPGRADE_LIST.map((def) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'upgrade-btn';
|
||||
btn.addEventListener('click', () => store.buyUpgrade(def.id));
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'upgrade-btn__header';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'upgrade-btn__name';
|
||||
name.textContent = def.name;
|
||||
const level = document.createElement('span');
|
||||
level.className = 'upgrade-btn__level';
|
||||
header.append(name, level);
|
||||
|
||||
const effects = document.createElement('div');
|
||||
effects.className = 'upgrade-btn__effects';
|
||||
const current = document.createElement('span');
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'upgrade-btn__arrow';
|
||||
arrow.textContent = '>';
|
||||
const next = document.createElement('span');
|
||||
effects.append(current, arrow, next);
|
||||
|
||||
const cost = document.createElement('div');
|
||||
cost.className = 'upgrade-btn__cost';
|
||||
cost.appendChild(pixelIcon(COIN_SPRITE, 16));
|
||||
const costValue = document.createElement('span');
|
||||
cost.appendChild(costValue);
|
||||
|
||||
btn.append(header, effects, cost);
|
||||
list.appendChild(btn);
|
||||
|
||||
return { def, btn, level, current, next, costValue };
|
||||
});
|
||||
|
||||
root.append(title, list);
|
||||
|
||||
const dispose = autorun(() => {
|
||||
for (const row of rows) {
|
||||
const level = store.upgradeLevels[row.def.id];
|
||||
const cost = costForNextLevel(row.def, level);
|
||||
row.btn.disabled = store.inventory.coins < cost;
|
||||
row.level.textContent = `Ур. ${level}`;
|
||||
row.current.textContent = row.def.formatEffect(row.def.effectAtLevel(level), level);
|
||||
row.next.textContent = row.def.formatEffect(row.def.effectAtLevel(level + 1), level + 1);
|
||||
row.costValue.textContent = String(cost);
|
||||
}
|
||||
});
|
||||
|
||||
return { el: root, dispose };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { autorun } from 'mobx';
|
||||
import { formatClock } from '../game/daynight';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { COIN_SPRITE, HEART_SPRITE, LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||
import { pixelIcon } from './pixelIcon';
|
||||
|
||||
export interface Mounted {
|
||||
el: HTMLElement;
|
||||
/** Stops the mobx autorun driving this element's text — call when the game screen unmounts. */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function slot(icon?: SVGSVGElement): { el: HTMLElement; text: HTMLSpanElement } {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'inventory-slot';
|
||||
if (icon)
|
||||
el.appendChild(icon);
|
||||
const text = document.createElement('span');
|
||||
el.appendChild(text);
|
||||
return { el, text };
|
||||
}
|
||||
|
||||
/** Builds the topbar (clock/hp/inventory/exit button), keeping its text in sync with the store via
|
||||
* a single mobx autorun — the DOM-side replacement for Topbar.tsx. */
|
||||
export function createTopbar(store: GameStore, onExitToMenu: () => void): Mounted {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'topbar';
|
||||
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'topbar__title';
|
||||
title.textContent = 'Forest';
|
||||
|
||||
const clock = slot();
|
||||
const hp = slot(pixelIcon(HEART_SPRITE, 18));
|
||||
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'spacer';
|
||||
|
||||
const twigs = slot(pixelIcon(TWIG_SPRITE, 22));
|
||||
const logs = slot(pixelIcon(LOG_SPRITE, 28));
|
||||
const meat = slot(pixelIcon(MEAT_SPRITE, 22));
|
||||
const coins = slot(pixelIcon(COIN_SPRITE, 24));
|
||||
|
||||
const exitBtn = document.createElement('button');
|
||||
exitBtn.type = 'button';
|
||||
exitBtn.className = 'exit-menu-btn';
|
||||
exitBtn.textContent = 'Выйти в меню';
|
||||
exitBtn.addEventListener('click', onExitToMenu);
|
||||
|
||||
root.append(
|
||||
title,
|
||||
clock.el,
|
||||
hp.el,
|
||||
spacer,
|
||||
twigs.el,
|
||||
logs.el,
|
||||
meat.el,
|
||||
coins.el,
|
||||
exitBtn,
|
||||
);
|
||||
|
||||
const dispose = autorun(() => {
|
||||
clock.text.textContent = `День ${store.day}, ${formatClock(store.hour)}`;
|
||||
hp.text.textContent = `${store.characterHp.toFixed(1)}/${store.characterMaxHp}`;
|
||||
twigs.text.textContent = String(store.inventory.twigs);
|
||||
logs.text.textContent = String(store.inventory.logs);
|
||||
meat.text.textContent = String(store.inventory.meat);
|
||||
coins.text.textContent = store.inventory.coins.toFixed(1);
|
||||
});
|
||||
|
||||
return { el: root, dispose };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PixelGrid } from '../game/sprites';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
/** Builds a small inline SVG for a PixelGrid (see game/sprites.ts) — the DOM-side replacement for
|
||||
* PixelSprite.tsx, used for the HUD's static icons (topbar/sidebar). The game world's own sprites
|
||||
* are baked into Pixi textures instead (see src/pixi/textures.ts); this is only for the handful of
|
||||
* fixed-size icons drawn in plain HTML. */
|
||||
export function pixelIcon(pixels: PixelGrid, size: number): SVGSVGElement {
|
||||
const rows = pixels.length;
|
||||
const cols = pixels[0]?.length ?? 0;
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||
svg.setAttribute('width', String(size));
|
||||
svg.setAttribute('height', String(cols > 0 ? size * (rows / cols) : size));
|
||||
svg.setAttribute('viewBox', `0 0 ${cols} ${rows}`);
|
||||
svg.setAttribute('shape-rendering', 'crispEdges');
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const color = pixels[y][x];
|
||||
if (!color)
|
||||
continue;
|
||||
const rect = document.createElementNS(SVG_NS, 'rect');
|
||||
rect.setAttribute('x', String(x));
|
||||
rect.setAttribute('y', String(y));
|
||||
rect.setAttribute('width', '1');
|
||||
rect.setAttribute('height', '1');
|
||||
rect.setAttribute('fill', color);
|
||||
svg.appendChild(rect);
|
||||
}
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,33 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { CHARACTER_SPRITE } from '../game/sprites';
|
||||
import HealthBar from './HealthBar';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
const CHARACTER_SIZE = 48;
|
||||
|
||||
const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="entity character-shadow"
|
||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
|
||||
/>
|
||||
<div
|
||||
className={`entity character ${store.isMoving ? 'character--walking' : ''} ${store.characterHitFlashTimer > 0 ? 'chopping' : ''}`}
|
||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
|
||||
>
|
||||
<PixelSprite pixels={CHARACTER_SPRITE} size={CHARACTER_SIZE} />
|
||||
</div>
|
||||
{store.characterHp < store.characterMaxHp && (
|
||||
<HealthBar
|
||||
x={store.character.x}
|
||||
y={store.character.y - CHARACTER_SIZE * 0.5 - 6}
|
||||
ratio={store.characterHp / store.characterMaxHp}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default CharacterEntity;
|
||||
@@ -1,225 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { PointerEvent } from 'react';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { overlayColorForHour } from '../game/daynight';
|
||||
import CharacterEntity from './CharacterEntity';
|
||||
import NodeEntity from './NodeEntity';
|
||||
import RabbitEntity from './RabbitEntity';
|
||||
import ResourceDropEntity from './ResourceDropEntity';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
onDeath: () => void;
|
||||
}
|
||||
|
||||
// Must mirror the @media rule in index.css that rotates #root into landscape on a portrait
|
||||
// touchscreen — when that rule is active, the browser paints everything rotated 90°, but
|
||||
// PointerEvent.clientX/clientY (and Element.getBoundingClientRect()) keep reporting the
|
||||
// physical, unrotated screen: physical x/y and physical width/height end up swapped relative to
|
||||
// the app's own (rotated) layout, and have to be converted back explicitly wherever they're used.
|
||||
const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)';
|
||||
|
||||
/** Russian plural agreement for "день" (day) after a count. */
|
||||
function dayWord(n: number): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11)
|
||||
return 'день';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
|
||||
return 'дня';
|
||||
return 'дней';
|
||||
}
|
||||
|
||||
const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
|
||||
return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
|
||||
});
|
||||
|
||||
const BloodMoonBanner = observer(function BloodMoonBanner({ store }: { store: GameStore }) {
|
||||
if (!store.bloodMoonBannerVisible)
|
||||
return null;
|
||||
return <div className="blood-moon-banner">КРОВАВАЯ ЛУНА!</div>;
|
||||
});
|
||||
|
||||
const GameOverOverlay = observer(function GameOverOverlay({
|
||||
store,
|
||||
onDeath,
|
||||
}: {
|
||||
store: GameStore;
|
||||
onDeath: () => void;
|
||||
}) {
|
||||
if (!store.isDead)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="game-over-overlay">
|
||||
<div className="game-over-overlay__message">
|
||||
Вы прожили {store.day} {dayWord(store.day)}, но вас поймали кролики
|
||||
</div>
|
||||
<div className="game-over-overlay__stats">
|
||||
<div>Пройдено: {Math.round(store.character.distanceTraveled)} px</div>
|
||||
<div>Срублено: {store.treesChoppedCount} деревьев, {store.bushesChoppedCount} кустов</div>
|
||||
<div>Поймано кроликов: {store.rabbitsCaughtCount}</div>
|
||||
<div>Заработано монет: {store.totalCoinsEarned.toFixed(1)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="menu-btn"
|
||||
// The whole map underneath (including this overlay) has a pointerdown handler that
|
||||
// captures the pointer and drives character movement — without stopping it here, that
|
||||
// handler fires first on bubble-up and swallows the tap before it becomes a click.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={onDeath}
|
||||
>
|
||||
В меню
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function GameMap({ store, onDeath }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const isRotatedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(ROTATE_QUERY);
|
||||
isRotatedRef.current = mql.matches;
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
isRotatedRef.current = e.matches;
|
||||
};
|
||||
mql.addEventListener('change', handleChange);
|
||||
return () => mql.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
// getBoundingClientRect() reports the physical (rotated) box, whose width/height are swapped
|
||||
// relative to the app's own logical layout — see ROTATE_QUERY above.
|
||||
const rect = container.getBoundingClientRect();
|
||||
const [width, height] = isRotatedRef.current ? [rect.height, rect.width] : [rect.width, rect.height];
|
||||
if (width > 0 && height > 0) {
|
||||
store.initialize(width, height);
|
||||
store.start();
|
||||
setReady(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
store.stop();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('blur', store.release);
|
||||
window.addEventListener('beforeunload', store.save);
|
||||
return () => {
|
||||
window.removeEventListener('blur', store.release);
|
||||
window.removeEventListener('beforeunload', store.save);
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
/** Finds the node or rabbit (if any) the pointer is currently over, by DOM hit-testing at its
|
||||
* client coordinates. elementFromPoint uses physical screen coordinates and already accounts
|
||||
* for any CSS transform, so this needs no rotation correction (unlike toLocalPoint below). */
|
||||
function targetAtPoint(clientX: number, clientY: number): { kind: 'node' | 'rabbit'; id: string } | null {
|
||||
const el = document.elementFromPoint(clientX, clientY);
|
||||
const nodeHit = el?.closest('[data-node-id]');
|
||||
if (nodeHit)
|
||||
return { kind: 'node', id: nodeHit.getAttribute('data-node-id')! };
|
||||
const rabbitHit = el?.closest('[data-rabbit-id]');
|
||||
if (rabbitHit)
|
||||
return { kind: 'rabbit', id: rabbitHit.getAttribute('data-rabbit-id')! };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Converts a pointer event's physical screen position into the map's own logical (x, y) —
|
||||
* i.e. what the game world uses for the character's target position. When the map is rotated
|
||||
* (see ROTATE_QUERY), physical x/y are swapped and one is mirrored relative to logical x/y, so
|
||||
* a plain subtraction of rect.left/rect.top would point the character the wrong way. */
|
||||
function toLocalPoint(e: PointerEvent<HTMLDivElement>, rect: DOMRect): { x: number; y: number } {
|
||||
const physicalX = e.clientX - rect.left;
|
||||
const physicalY = e.clientY - rect.top;
|
||||
if (isRotatedRef.current)
|
||||
return { x: physicalY, y: rect.width - physicalX };
|
||||
return { x: physicalX, y: physicalY };
|
||||
}
|
||||
|
||||
/** The character's action is purely a function of the current pointer state: if the primary
|
||||
* button isn't held, nothing happens; otherwise it heads for whatever's under the cursor right
|
||||
* now — a node to chop, or open ground to walk to. Shared by pointerdown/move/up so panning the
|
||||
* cursor across a node mid-drag starts/stops chopping it, and releasing the button stops it. */
|
||||
function followPointer(e: PointerEvent<HTMLDivElement>) {
|
||||
if ((e.buttons & 1) === 0) {
|
||||
store.release();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = targetAtPoint(e.clientX, e.clientY);
|
||||
if (target) {
|
||||
if (target.kind === 'node')
|
||||
store.holdNode(target.id);
|
||||
else
|
||||
store.holdRabbit(target.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing's under the cursor right now. Unlike a stationary tree/bush, a rabbit can jump out
|
||||
// from under an otherwise-still cursor entirely on its own — that shouldn't read as "the
|
||||
// player moved off the target" the way it would for a stationary node, or every autonomous
|
||||
// jump would silently drop the attack and send the character off to walk to empty ground
|
||||
// instead. Keep chasing the same rabbit until the player actually points at something else or
|
||||
// releases the button.
|
||||
if (store.heldRabbitId)
|
||||
return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const { x, y } = toLocalPoint(e, rect);
|
||||
store.moveTo(x, y);
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
followPointer(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-map"
|
||||
ref={containerRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={followPointer}
|
||||
onPointerUp={followPointer}
|
||||
onPointerCancel={store.release}
|
||||
>
|
||||
{ready && (
|
||||
<>
|
||||
{store.resourceDrops.map((drop) => (
|
||||
<ResourceDropEntity key={drop.id} drop={drop} />
|
||||
))}
|
||||
|
||||
{store.nodes.map((node) => (
|
||||
<NodeEntity key={node.id} node={node} store={store} />
|
||||
))}
|
||||
|
||||
{store.rabbits.map((rabbit) => (
|
||||
<RabbitEntity key={rabbit.id} rabbit={rabbit} store={store} />
|
||||
))}
|
||||
|
||||
<CharacterEntity store={store} />
|
||||
|
||||
<DayNightOverlay store={store} />
|
||||
|
||||
<BloodMoonBanner store={store} />
|
||||
|
||||
<GameOverOverlay store={store} onDeath={onDeath} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GameMap);
|
||||
@@ -1,10 +0,0 @@
|
||||
/** A small floating bar above an entity's sprite, anchored bottom-center at (x, yAboveSprite). */
|
||||
function HealthBar({ x, y, ratio }: { x: number; y: number; ratio: number }) {
|
||||
return (
|
||||
<div className="health-bar" style={{ left: x, top: y }}>
|
||||
<div className="health-bar__fill" style={{ width: `${Math.max(0, Math.min(1, ratio)) * 100}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HealthBar;
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { hasSave } from '../game/persistence';
|
||||
|
||||
interface Props {
|
||||
onNewGame: () => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
function MainMenu({ onNewGame, onContinue }: Props) {
|
||||
// Computed once per mount — the menu remounts fresh every time the player returns to it (see
|
||||
// App), so this naturally stays in sync with whether a save exists.
|
||||
const [canContinue] = useState(hasSave);
|
||||
|
||||
return (
|
||||
<div className="main-menu">
|
||||
<h1 className="main-menu__title">Forest</h1>
|
||||
<div className="main-menu__buttons">
|
||||
<button type="button" className="menu-btn" onClick={onNewGame}>
|
||||
Новая игра
|
||||
</button>
|
||||
<button type="button" className="menu-btn" disabled={!canContinue} onClick={onContinue}>
|
||||
Продолжить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainMenu;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import type { ResourceNode } from '../game/entities/ResourceNode';
|
||||
import { BUSH_SPRITE, TREE_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
const NODE_SPRITES = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
|
||||
const NODE_SIZES = { tree: 64, bush: 40 };
|
||||
|
||||
const NodeEntity = observer(function NodeEntity({ node, store }: { node: ResourceNode; store: GameStore }) {
|
||||
const isChopping = node.id === store.heldNodeId && store.isChopping;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`entity ${node.kind}-shadow`} style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) - 1 }} />
|
||||
<div
|
||||
className={`entity ${node.kind} ${isChopping ? 'chopping' : ''}`}
|
||||
style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) }}
|
||||
data-node-id={node.id}
|
||||
>
|
||||
<PixelSprite pixels={NODE_SPRITES[node.kind]} size={NODE_SIZES[node.kind]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default NodeEntity;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import type { PixelGrid } from '../game/sprites';
|
||||
|
||||
interface Props {
|
||||
pixels: PixelGrid;
|
||||
size: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PixelSprite({ pixels, size, className }: Props) {
|
||||
const rows = pixels.length;
|
||||
const cols = pixels[0]?.length ?? 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={cols > 0 ? size * (rows / cols) : size}
|
||||
viewBox={`0 0 ${cols} ${rows}`}
|
||||
shapeRendering="crispEdges"
|
||||
>
|
||||
{pixels.flatMap((row, y) =>
|
||||
row.map((color, x) =>
|
||||
color ? <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={color} /> : null,
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// `pixels` is always one of the module-level sprite constants and `size` is a literal passed by
|
||||
// the caller, so both are reference/value-stable across re-renders — memoizing means an entity
|
||||
// re-rendering every frame for its position (left/top) doesn't also regenerate this sprite's
|
||||
// (potentially hundreds of) <rect> elements each time.
|
||||
export default memo(PixelSprite);
|
||||
@@ -1,33 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
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: Rabbit; store: GameStore }) {
|
||||
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="entity rabbit-shadow"
|
||||
style={{ left: rabbit.x, top: rabbit.y, width: size, height: size / 2, marginTop: size * 0.6 }}
|
||||
/>
|
||||
<div
|
||||
className={`entity rabbit ${rabbit.hitFlashTimer > 0 ? 'chopping' : ''}`}
|
||||
style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }}
|
||||
data-rabbit-id={rabbit.id}
|
||||
>
|
||||
<PixelSprite pixels={store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE} size={size} />
|
||||
</div>
|
||||
{rabbit.hp < store.rabbitMaxHp && (
|
||||
<HealthBar x={rabbit.x} y={rabbit.y - rabbit.z - size * 0.5 - 6} ratio={rabbit.hp / store.rabbitMaxHp} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default RabbitEntity;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { ResourceDrop } from '../game/entities/ResourceDrop';
|
||||
import { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
const DROP_SPRITES = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE };
|
||||
const DROP_SIZES = { log: 20, twig: 18, meat: 18 };
|
||||
|
||||
const ResourceDropEntity = observer(function ResourceDropEntity({ drop }: { drop: ResourceDrop }) {
|
||||
return (
|
||||
<div>
|
||||
<div className={`entity ${drop.resource}-shadow`} style={{ left: drop.x, top: drop.y }} />
|
||||
<div
|
||||
className={`entity ${drop.resource}`}
|
||||
style={{ left: drop.x, top: drop.y - drop.z, zIndex: Math.floor(drop.y - drop.z) }}
|
||||
>
|
||||
<PixelSprite pixels={DROP_SPRITES[drop.resource]} size={DROP_SIZES[drop.resource]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ResourceDropEntity;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { costForNextLevel, UPGRADE_LIST } from '../game/upgrades';
|
||||
import { COIN_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
}
|
||||
|
||||
function Sidebar({ store }: Props) {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<h2 className="sidebar__title">Апгрейды</h2>
|
||||
<div className="upgrade-list">
|
||||
{UPGRADE_LIST.map((def) => {
|
||||
const level = store.upgradeLevels[def.id];
|
||||
const cost = costForNextLevel(def, level);
|
||||
const canAfford = store.inventory.coins >= cost;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={def.id}
|
||||
type="button"
|
||||
className="upgrade-btn"
|
||||
disabled={!canAfford}
|
||||
onClick={() => store.buyUpgrade(def.id)}
|
||||
>
|
||||
<div className="upgrade-btn__header">
|
||||
<span className="upgrade-btn__name">{def.name}</span>
|
||||
<span className="upgrade-btn__level">Ур. {level}</span>
|
||||
</div>
|
||||
<div className="upgrade-btn__effects">
|
||||
<span>{def.formatEffect(def.effectAtLevel(level), level)}</span>
|
||||
<span className="upgrade-btn__arrow">></span>
|
||||
<span>{def.formatEffect(def.effectAtLevel(level + 1), level + 1)}</span>
|
||||
</div>
|
||||
<div className="upgrade-btn__cost">
|
||||
<PixelSprite pixels={COIN_SPRITE} size={16} />
|
||||
<span>{cost}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Sidebar);
|
||||
@@ -1,47 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { formatClock } from '../game/daynight';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { COIN_SPRITE, HEART_SPRITE, LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
onExitToMenu: () => void;
|
||||
}
|
||||
|
||||
function Topbar({ store, onExitToMenu }: Props) {
|
||||
return (
|
||||
<div className="topbar">
|
||||
<h1 className="topbar__title">Forest</h1>
|
||||
<div className="inventory-slot">
|
||||
<span>День {store.day}, {formatClock(store.hour)}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={HEART_SPRITE} size={18} />
|
||||
<span>{store.characterHp.toFixed(1)}/{store.characterMaxHp}</span>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={TWIG_SPRITE} size={22} />
|
||||
<span>{store.inventory.twigs}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={LOG_SPRITE} size={28} />
|
||||
<span>{store.inventory.logs}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={MEAT_SPRITE} size={22} />
|
||||
<span>{store.inventory.meat}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={COIN_SPRITE} size={24} />
|
||||
<span>{store.inventory.coins.toFixed(1)}</span>
|
||||
</div>
|
||||
<button type="button" className="exit-menu-btn" onClick={onExitToMenu}>
|
||||
Выйти в меню
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Topbar);
|
||||
+72
-11
@@ -17,9 +17,11 @@ import {
|
||||
RABBIT_STRIKE_RANGE,
|
||||
rabbitsForDay,
|
||||
} from './rabbits';
|
||||
import { Random, randomSeed } from './random';
|
||||
import { pickSpawnPositions } from './spawning';
|
||||
import type { Inventory, ResourceType, Vec2 } from './types';
|
||||
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
|
||||
import type { Analytics } from '../analytics';
|
||||
|
||||
/** How often (seconds of played time) the game autosaves while running. */
|
||||
const AUTOSAVE_INTERVAL = 2;
|
||||
@@ -68,6 +70,9 @@ export class GameStore {
|
||||
/** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */
|
||||
bloodMoonBannerTimer = 0;
|
||||
upgradeLevels: Record<UpgradeId, number> = { ...DEFAULT_UPGRADE_LEVELS };
|
||||
/** This playthrough's PRNG seed — shown/settable in Settings (see SettingsScreen), persisted so
|
||||
* a save's random sequence is reproducible across a reload. */
|
||||
seed = 0;
|
||||
|
||||
// This life's run-summary stats, shown on the death screen — reset to 0 on a fresh game,
|
||||
// restored from the save when continuing one (see initialize()).
|
||||
@@ -77,7 +82,15 @@ export class GameStore {
|
||||
/** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes
|
||||
* down when coins are spent on upgrades. */
|
||||
totalCoinsEarned = 0;
|
||||
/** Real seconds elapsed this life (ticked deltaTime, not in-game clock time) — stops
|
||||
* accumulating once dead, same as the other life stats above (tick() returns early then). */
|
||||
playTimeSeconds = 0;
|
||||
|
||||
/** playTimeSeconds' value at the moment the current day/level started — not persisted, so
|
||||
* continuing a saved game restarts this day's timer from the moment of resuming rather than
|
||||
* from when that day originally began (a minor approximation, not worth the extra save fields
|
||||
* to avoid). Used to compute completeLevel()'s elapsed-seconds argument at the next dawn. */
|
||||
private levelStartPlayTime = 0;
|
||||
private coinProgress = 0;
|
||||
private saveProgress = 0;
|
||||
private lastTimestamp: number | null = null;
|
||||
@@ -87,8 +100,18 @@ export class GameStore {
|
||||
private lastAnnouncedBloodMoonDay = -1;
|
||||
/** Map dimensions from the last initialize(), reused to grow each new day's nodes. */
|
||||
private mapSize: { width: number; height: number } | null = null;
|
||||
/** Every gameplay-affecting random decision goes through this instead of Math.random() — see
|
||||
* random.ts. (Re)created fresh in initialize(), seeded from `seed`. */
|
||||
private random: Random = new Random(0);
|
||||
/** Set by setNextSeed() to override the seed the *next* initialize() picks (a fresh random one,
|
||||
* or the save's) — used when Settings starts a new game with an explicit seed. Consumed (reset
|
||||
* to null) the instant initialize() reads it. */
|
||||
private pendingSeed: number | null = null;
|
||||
|
||||
constructor() {
|
||||
private readonly analytics: Analytics;
|
||||
|
||||
constructor(analytics: Analytics) {
|
||||
this.analytics = analytics;
|
||||
// autoBind so methods stay correctly bound when passed as bare references (event handlers,
|
||||
// e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`).
|
||||
// Deliberately no side effects (e.g. global event listener registration) here beyond
|
||||
@@ -96,7 +119,11 @@ export class GameStore {
|
||||
// initializer in dev, constructing (and discarding) an extra instance — any listener
|
||||
// registered in the constructor would leak and fire for that discarded instance too. Global
|
||||
// listeners like beforeunload belong in a useEffect instead (see GameMap), which StrictMode
|
||||
// handles correctly via its mount/cleanup/remount cycle.
|
||||
// handles correctly via its mount/cleanup/remount cycle. The same reasoning is why the caller
|
||||
// must pass an already-built AnalyticsProvider (a module-level singleton — see App.tsx)
|
||||
// rather than GameStore constructing a GameAnalyticsProvider itself: that class registers its
|
||||
// own listeners and starts a session in its constructor, so double-invocation would leak a
|
||||
// listener set and open two sessions.
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
@@ -238,9 +265,16 @@ export class GameStore {
|
||||
|
||||
this.inventory.coins -= cost;
|
||||
this.upgradeLevels[id] = level + 1;
|
||||
this.analytics.buyUpgrade(id, level + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pins the seed the *next* initialize() call will use, overriding both a fresh random seed and
|
||||
* any existing save's seed — used when Settings starts a new game with an explicit seed. */
|
||||
setNextSeed(seed: number) {
|
||||
this.pendingSeed = seed;
|
||||
}
|
||||
|
||||
/** (Re)initializes the game for a map of the given size. Restores a saved game (character/nodes/
|
||||
* drops/progress) if one exists; otherwise places the character in the center and scatters day
|
||||
* 1's trees and bushes. */
|
||||
@@ -259,6 +293,14 @@ export class GameStore {
|
||||
this.day = save?.day ?? 1;
|
||||
this.hour = save?.hour ?? 12;
|
||||
|
||||
// An explicit seed (from Settings) wins over a saved game's own seed, which in turn wins over
|
||||
// a fresh random one — a save's rngState resumes its exact sequence, but only when its seed
|
||||
// wasn't just overridden out from under it.
|
||||
const explicitSeed = this.pendingSeed;
|
||||
this.pendingSeed = null;
|
||||
this.seed = explicitSeed ?? save?.seed ?? randomSeed();
|
||||
this.random = new Random(explicitSeed === null ? save?.rngState ?? this.seed : this.seed);
|
||||
|
||||
// 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
|
||||
@@ -281,6 +323,8 @@ export class GameStore {
|
||||
this.bushesChoppedCount = save?.bushesChoppedCount ?? 0;
|
||||
this.rabbitsCaughtCount = save?.rabbitsCaughtCount ?? 0;
|
||||
this.totalCoinsEarned = save?.totalCoinsEarned ?? 0;
|
||||
this.playTimeSeconds = save?.playTimeSeconds ?? 0;
|
||||
this.levelStartPlayTime = this.playTimeSeconds;
|
||||
|
||||
// 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.
|
||||
@@ -333,14 +377,17 @@ export class GameStore {
|
||||
bushesChoppedCount: this.bushesChoppedCount,
|
||||
rabbitsCaughtCount: this.rabbitsCaughtCount,
|
||||
totalCoinsEarned: this.totalCoinsEarned,
|
||||
playTimeSeconds: this.playTimeSeconds,
|
||||
seed: this.seed,
|
||||
rngState: this.random.getState(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 = pickSpawnPositions(treesForDay(this.day), width, height, []);
|
||||
const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions);
|
||||
const treePositions = pickSpawnPositions(treesForDay(this.day), width, height, [], this.random);
|
||||
const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions, this.random);
|
||||
return [
|
||||
...treePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
|
||||
...bushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)),
|
||||
@@ -358,8 +405,8 @@ 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 = pickSpawnPositions(treeDeficit, width, height, existing);
|
||||
const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions]);
|
||||
const newTreePositions = pickSpawnPositions(treeDeficit, width, height, existing, this.random);
|
||||
const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions], this.random);
|
||||
const newNodes = [
|
||||
...newTreePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
|
||||
...newBushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)),
|
||||
@@ -370,8 +417,8 @@ export class GameStore {
|
||||
}
|
||||
|
||||
private spawnRabbits(width: number, height: number, count: number): Rabbit[] {
|
||||
const positions = pickSpawnPositions(count, width, height, []);
|
||||
return positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp));
|
||||
const positions = pickSpawnPositions(count, width, height, [], this.random);
|
||||
return positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp, this.random));
|
||||
}
|
||||
|
||||
/** Tops the rabbit population up to this day's cap (never removes any — the cap only ever
|
||||
@@ -385,8 +432,8 @@ export class GameStore {
|
||||
return;
|
||||
|
||||
const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y }));
|
||||
const positions = pickSpawnPositions(deficit, width, height, existing);
|
||||
this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp))];
|
||||
const positions = pickSpawnPositions(deficit, width, height, existing, this.random);
|
||||
this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp, this.random))];
|
||||
}
|
||||
|
||||
holdNode(id: string) {
|
||||
@@ -461,6 +508,7 @@ export class GameStore {
|
||||
if (this.isDead)
|
||||
return;
|
||||
|
||||
this.playTimeSeconds += deltaTime;
|
||||
this.updateClock(deltaTime);
|
||||
this.updateBloodMoon(deltaTime);
|
||||
this.updateMovementAndChopping(deltaTime);
|
||||
@@ -510,6 +558,7 @@ export class GameStore {
|
||||
this.characterHitFlashTimer = HIT_FLASH_DURATION;
|
||||
if (this.character.hp <= 0) {
|
||||
this.isDead = true;
|
||||
this.analytics.death('killed_by_rabbit');
|
||||
clearSave();
|
||||
}
|
||||
}
|
||||
@@ -518,6 +567,7 @@ export class GameStore {
|
||||
* (upgradable) day speed otherwise — rolling over into a new day, which grows fresh plants and
|
||||
* rabbits up to the new day's caps, whenever it passes midnight. */
|
||||
private updateClock(deltaTime: number) {
|
||||
const wasNight = this.isNight;
|
||||
const speed = this.isNight ? NIGHT_SPEED : this.daySpeedHoursPerSecond;
|
||||
this.hour += speed * deltaTime;
|
||||
while (this.hour >= 24) {
|
||||
@@ -526,6 +576,16 @@ export class GameStore {
|
||||
this.growNewNodesForDay();
|
||||
this.growRabbitsForDay();
|
||||
}
|
||||
// Edge-triggered on the night->day flip rather than tracking a "last announced day", so it
|
||||
// fires exactly once per dawn regardless of day-rollover timing — and never for day 1's own
|
||||
// dawn, since a fresh game starts at noon (no night->day transition has happened yet).
|
||||
if (wasNight && !this.isNight) {
|
||||
const completedDay = this.day - 1;
|
||||
const elapsedSeconds = this.playTimeSeconds - this.levelStartPlayTime;
|
||||
this.analytics.completeLevel(completedDay, elapsedSeconds);
|
||||
this.levelStartPlayTime = this.playTimeSeconds;
|
||||
this.analytics.startLevel(this.day);
|
||||
}
|
||||
}
|
||||
|
||||
/** Each rabbit independently cycles: pause (deciding its next action) → jump or strike
|
||||
@@ -543,6 +603,7 @@ export class GameStore {
|
||||
mapWidth: this.mapSize.width,
|
||||
mapHeight: this.mapSize.height,
|
||||
onStrike: () => this.landRabbitStrike(),
|
||||
random: this.random,
|
||||
};
|
||||
for (const rabbit of this.rabbits) {
|
||||
rabbit.regenerateHealth(deltaTime, RABBIT_HEALTH_REGEN_INTERVAL, maxHp);
|
||||
@@ -661,7 +722,7 @@ export class GameStore {
|
||||
}
|
||||
|
||||
private ejectDrop(origin: Vec2, resource: ResourceType) {
|
||||
this.resourceDrops.push(ResourceDrop.eject(origin, resource));
|
||||
this.resourceDrops.push(ResourceDrop.eject(origin, resource, this.random));
|
||||
}
|
||||
|
||||
/** Advances every resource drop currently in a ballistic flight (falling from a node, or flying
|
||||
|
||||
@@ -13,31 +13,31 @@ export const TWIGS_PER_TREE = 2;
|
||||
export const TWIGS_PER_BUSH = 5;
|
||||
|
||||
/** How close the character must get to a node's center to start chopping it. */
|
||||
export const INTERACTION_RADIUS = 56;
|
||||
export const INTERACTION_RADIUS = 19;
|
||||
|
||||
/** Radius of the circle around a node that ejected resources can land in. */
|
||||
export const DROP_EJECT_RADIUS = 70;
|
||||
export const DROP_EJECT_RADIUS = 23;
|
||||
|
||||
/** Seconds for a resource's ballistic flight from the node to its landing spot. */
|
||||
export const DROP_FALL_DURATION = 0.5;
|
||||
|
||||
/** Peak height (px) of a resource's arc while falling from the node. */
|
||||
export const DROP_FALL_ARC_HEIGHT = 40;
|
||||
export const DROP_FALL_ARC_HEIGHT = 13;
|
||||
|
||||
/** Seconds for a landed resource's flight into the character's inventory. */
|
||||
export const DROP_COLLECT_DURATION = 0.25;
|
||||
|
||||
/** Peak height (px) of a resource's little hop while flying into the inventory. */
|
||||
export const DROP_COLLECT_ARC_HEIGHT = 16;
|
||||
export const DROP_COLLECT_ARC_HEIGHT = 5;
|
||||
|
||||
/** Nodes (trees/bushes) won't spawn closer than this to the character's starting position. */
|
||||
export const ENTITY_MIN_DISTANCE_FROM_CENTER = 140;
|
||||
export const ENTITY_MIN_DISTANCE_FROM_CENTER = 47;
|
||||
|
||||
/** Nodes won't spawn closer than this to each other, regardless of kind. */
|
||||
export const ENTITY_MIN_DISTANCE_BETWEEN = 100;
|
||||
export const ENTITY_MIN_DISTANCE_BETWEEN = 33;
|
||||
|
||||
/** Nodes won't spawn within this many px of the map edge. */
|
||||
export const ENTITY_EDGE_MARGIN = 70;
|
||||
export const ENTITY_EDGE_MARGIN = 23;
|
||||
|
||||
/** How long the hit-shake animation plays once a hit actually lands (character or rabbit) —
|
||||
* a brief flash at the moment of impact, not for however long the attack itself takes. */
|
||||
|
||||
@@ -61,9 +61,9 @@ const OVERLAY_KEYFRAMES: OverlayKeyframe[] = [
|
||||
{ hour: 24, r: 10, g: 10, b: 35, a: 0.55 },
|
||||
];
|
||||
|
||||
/** The ambient darkness/tint overlay for the given time of day, as a CSS rgba() string —
|
||||
* interpolated piecewise-linearly between OVERLAY_KEYFRAMES. */
|
||||
export function overlayColorForHour(hour: number): string {
|
||||
/** The ambient darkness/tint overlay for the given time of day, as {r,g,b,a} (0-255 channels,
|
||||
* 0-1 alpha) — interpolated piecewise-linearly between OVERLAY_KEYFRAMES. */
|
||||
export function overlayColorComponentsForHour(hour: number): { r: number; g: number; b: number; a: number } {
|
||||
const h = ((hour % 24) + 24) % 24;
|
||||
let prev = OVERLAY_KEYFRAMES[0];
|
||||
let next = OVERLAY_KEYFRAMES[OVERLAY_KEYFRAMES.length - 1];
|
||||
@@ -78,9 +78,5 @@ export function overlayColorForHour(hour: number): string {
|
||||
const span = next.hour - prev.hour;
|
||||
const t = span === 0 ? 0 : (h - prev.hour) / span;
|
||||
const lerp = (a: number, b: number) => a + (b - a) * t;
|
||||
const r = lerp(prev.r, next.r);
|
||||
const g = lerp(prev.g, next.g);
|
||||
const b = lerp(prev.b, next.b);
|
||||
const a = lerp(prev.a, next.a);
|
||||
return `rgba(${r.toFixed(0)}, ${g.toFixed(0)}, ${b.toFixed(0)}, ${a.toFixed(3)})`;
|
||||
return { r: lerp(prev.r, next.r), g: lerp(prev.g, next.g), b: lerp(prev.b, next.b), a: lerp(prev.a, next.a) };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import { ENTITY_EDGE_MARGIN } from '../constants';
|
||||
import { randomId } from '../id';
|
||||
import type { Random } from '../random';
|
||||
import {
|
||||
RABBIT_ACTION_DURATION,
|
||||
RABBIT_JUMP_ARC_HEIGHT,
|
||||
@@ -24,6 +25,9 @@ export interface RabbitTickContext {
|
||||
/** Called the instant a strike begins — damage lands at the *start* of the action, not once it
|
||||
* finishes playing out. */
|
||||
onStrike: () => void;
|
||||
/** The game's seeded PRNG (see GameStore) — every random jump-direction decision goes through
|
||||
* this instead of Math.random(), so a whole playthrough is reproducible from its seed. */
|
||||
random: Random;
|
||||
}
|
||||
|
||||
/** A rabbit: hops in random directions by day (never too close to the character), and hunts them
|
||||
@@ -82,8 +86,8 @@ export class Rabbit {
|
||||
|
||||
/** 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);
|
||||
static spawnNew(pos: Vec2, maxHp: number, random: Random): Rabbit {
|
||||
return new Rabbit(pos.x, pos.y, maxHp, 'idle', random.next() * RABBIT_PAUSE_DURATION);
|
||||
}
|
||||
|
||||
/** Reconstructs a rabbit from saved data. Every field gets an explicit fallback since a save
|
||||
@@ -211,7 +215,7 @@ export class Rabbit {
|
||||
// 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;
|
||||
angle = towardCharacter + (ctx.random.next() * 2 - 1) * spread;
|
||||
jumpDistance = Math.min(RABBIT_JUMP_DISTANCE, distanceToCharacter);
|
||||
} else {
|
||||
angle = this.pickDayJumpAngle(ctx);
|
||||
@@ -241,7 +245,7 @@ export class Rabbit {
|
||||
return awayAngle;
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const angle = ctx.random.next() * 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);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DROP_FALL_DURATION,
|
||||
} from '../constants';
|
||||
import { randomId } from '../id';
|
||||
import type { Random } from '../random';
|
||||
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
|
||||
@@ -35,9 +36,9 @@ export class ResourceDrop {
|
||||
|
||||
/** 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;
|
||||
static eject(origin: Vec2, resource: ResourceType, random: Random): ResourceDrop {
|
||||
const angle = random.next() * Math.PI * 2;
|
||||
const landDistance = random.next() * 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', {
|
||||
|
||||
@@ -29,6 +29,16 @@ export interface SaveData {
|
||||
/** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes
|
||||
* down when coins are spent on upgrades. */
|
||||
totalCoinsEarned?: number;
|
||||
/** Real seconds elapsed this life (ticked deltaTime, not in-game clock time) — stops
|
||||
* accumulating once dead, same as the other life stats above. */
|
||||
playTimeSeconds?: number;
|
||||
/** The seed this playthrough's PRNG (see game/random.ts) was started from — absent on saves from
|
||||
* before it existed, which get a fresh random one instead (see GameStore.initialize). */
|
||||
seed?: number;
|
||||
/** The PRNG's internal state as of this save, so continuing resumes its exact random sequence
|
||||
* instead of replaying from the start of `seed`'s sequence again. Absent on saves from before it
|
||||
* existed (or from a version mismatch), which fall back to `seed` itself. */
|
||||
rngState?: number;
|
||||
}
|
||||
|
||||
function isValidSave(value: unknown): value is SaveData {
|
||||
|
||||
+5
-5
@@ -10,20 +10,20 @@ export const RABBIT_ACTION_DURATION = 0.5;
|
||||
export const RABBIT_PAUSE_DURATION = 0.5;
|
||||
|
||||
/** Max distance covered by a single jump. */
|
||||
export const RABBIT_JUMP_DISTANCE = 80;
|
||||
export const RABBIT_JUMP_DISTANCE = 27;
|
||||
|
||||
/** Peak height (px) of a jump's parabolic arc — same idea as a resource drop's arc when it flies
|
||||
* off a chopped tree. */
|
||||
export const RABBIT_JUMP_ARC_HEIGHT = 30;
|
||||
export const RABBIT_JUMP_ARC_HEIGHT = 10;
|
||||
|
||||
/** By day, a rabbit won't choose a jump target closer than this to the character. */
|
||||
export const RABBIT_MIN_DISTANCE_FROM_CHARACTER = 130;
|
||||
export const RABBIT_MIN_DISTANCE_FROM_CHARACTER = 43;
|
||||
|
||||
/** How close the character must be, by night, for a rabbit to strike instead of jumping toward them. */
|
||||
export const RABBIT_STRIKE_RANGE = 28;
|
||||
export const RABBIT_STRIKE_RANGE = 9;
|
||||
|
||||
/** How close the character must get to hit a rabbit. */
|
||||
export const RABBIT_HIT_RADIUS = 40;
|
||||
export const RABBIT_HIT_RADIUS = 13;
|
||||
|
||||
/** A rabbit's max HP (fixed — unlike the character's, there's no upgrade for it). */
|
||||
export const RABBIT_MAX_HP = 3;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** A small, fast, seedable PRNG (mulberry32) — same `next()` contract as `Math.random()` (a float
|
||||
* in [0, 1)), but deterministic: the same seed always produces the same sequence. Every gameplay-
|
||||
* affecting random decision (node/rabbit spawn positions, jump directions, drop landing spots —
|
||||
* see GameStore's own `random` field) goes through an instance of this instead of `Math.random()`
|
||||
* directly, so a whole playthrough is reproducible from its seed alone. (Entity id strings — see
|
||||
* id.ts — are the one exception: they're just internal bookkeeping labels, never read by gameplay
|
||||
* logic, so they're left on plain `Math.random()` rather than threading a Random instance through
|
||||
* every entity constructor for no gameplay-visible benefit.) */
|
||||
export class Random {
|
||||
private state: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
// >>> 0 folds any float/negative/out-of-range input down to a valid 32-bit unsigned seed.
|
||||
this.state = seed >>> 0;
|
||||
}
|
||||
|
||||
/** A float in [0, 1), same contract as Math.random(). */
|
||||
next(): number {
|
||||
this.state = (this.state + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(this.state ^ (this.state >>> 15), this.state | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/** The PRNG's current internal state — save this (not just the original seed) to resume the
|
||||
* exact same random sequence later, e.g. across a save/continue (see GameStore), rather than
|
||||
* replaying from the very start of the seed's sequence again. */
|
||||
getState(): number {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates a fresh seed for a new game — the one place actual (non-reproducible) randomness is
|
||||
* still used, since a *new* game is supposed to be different each time by default (see
|
||||
* GameStore.initialize). Settings lets a player pin a specific seed instead — see
|
||||
* game/seedSettings.ts. */
|
||||
export function randomSeed(): number {
|
||||
return Math.floor(Math.random() * 0xffffffff);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ENTITY_EDGE_MARGIN, ENTITY_MIN_DISTANCE_BETWEEN, ENTITY_MIN_DISTANCE_FROM_CENTER } from './constants';
|
||||
import type { Random } from './random';
|
||||
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[] {
|
||||
export function pickSpawnPositions(count: number, width: number, height: number, existing: Vec2[], random: Random): Vec2[] {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const positions: Vec2[] = [];
|
||||
@@ -12,8 +13,8 @@ export function pickSpawnPositions(count: number, width: number, height: number,
|
||||
|
||||
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);
|
||||
const x = ENTITY_EDGE_MARGIN + random.next() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2);
|
||||
const y = ENTITY_EDGE_MARGIN + random.next() * Math.max(0, height - ENTITY_EDGE_MARGIN * 2);
|
||||
|
||||
if (Math.hypot(x - centerX, y - centerY) < ENTITY_MIN_DISTANCE_FROM_CENTER)
|
||||
continue;
|
||||
|
||||
@@ -27,7 +27,7 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
||||
name: 'Скорость движения',
|
||||
baseCost: 5,
|
||||
costGrowth: 1.35,
|
||||
effectAtLevel: (level) => 160 + (level - 1) * 20,
|
||||
effectAtLevel: (level) => 50 + (level - 1) * 6,
|
||||
formatEffect: (v) => `${Math.round(v)} px/с`,
|
||||
},
|
||||
{
|
||||
@@ -67,7 +67,7 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
||||
name: 'Радиус подбора',
|
||||
baseCost: 5,
|
||||
costGrowth: 1.3,
|
||||
effectAtLevel: (level) => 46 + (level - 1) * 6,
|
||||
effectAtLevel: (level) => 15 + (level - 1) * 2,
|
||||
formatEffect: (v) => `${Math.round(v)} px`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/* Self-hosted (see public/fonts) instead of Google Fonts' CDN — latin + cyrillic covers every
|
||||
* character the UI actually uses (Russian text, digits, Latin punctuation). */
|
||||
@font-face {
|
||||
font-family: 'Press Start 2P';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/press-start-2p-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Press Start 2P';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/press-start-2p-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
:root {
|
||||
--panel-bg: #3a2a1e;
|
||||
--panel-border: #1b1611;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import './index.css';
|
||||
import { App } from './app/App';
|
||||
|
||||
// Best-effort: the browser only grants this in contexts like fullscreen or an installed PWA, and
|
||||
// throws everywhere else — the CSS rotate fallback in index.css covers the rest.
|
||||
screen.orientation?.lock?.('landscape').catch(() => {});
|
||||
|
||||
new App(document.getElementById('root')!);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Best-effort: the browser only grants this in contexts like fullscreen or an installed PWA, and
|
||||
// throws everywhere else — the CSS rotate fallback in index.css covers the rest.
|
||||
screen.orientation?.lock?.('landscape').catch(() => {})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Sprite, type ApplicationOptions } from 'pixi.js';
|
||||
import { CHARACTER_SPRITE } from '../game/sprites';
|
||||
import { PixelPerfectWorld } from './PixelPerfectWorld';
|
||||
import { getSpriteTexture } from './textures';
|
||||
|
||||
// Must comfortably fit the character's own native rendered box (16x22 — its grid's own size,
|
||||
// see textures.ts) or the RenderTexture bake clips it at the world's own edge, before any of the
|
||||
// world-to-screen k-scaling even happens.
|
||||
const WORLD_SIZE = 32;
|
||||
|
||||
/** Draws the character sprite once, at a fixed size, through the exact same pipeline the real
|
||||
* game world uses (see PixelPerfectWorld/GameCanvas) — a standalone visual sanity check for
|
||||
* "are pixels actually square and uniform on this screen," reachable from the main menu without
|
||||
* starting a game. Wired into MainMenu.ts as a temporary debugging aid; safe to remove (along with
|
||||
* its call site there) once no longer needed. `appOptions` is a debugging escape hatch (e.g.
|
||||
* `{ preserveDrawingBuffer: true }` to read the canvas back with getImageData) — MainMenu.ts's
|
||||
* call site doesn't pass any. */
|
||||
export async function mountDebugCharacterPreview(
|
||||
container: HTMLElement,
|
||||
appOptions?: Partial<ApplicationOptions>,
|
||||
): Promise<{ destroy: () => void; world: PixelPerfectWorld }> {
|
||||
const world = new PixelPerfectWorld();
|
||||
// No PixelPerfectOptions passed — this preview always stays pixel-perfect (the default), as a
|
||||
// fixed reference regardless of what the player has toggled in Settings.
|
||||
await world.mount(container, WORLD_SIZE, WORLD_SIZE, undefined, appOptions);
|
||||
|
||||
const info = getSpriteTexture(CHARACTER_SPRITE);
|
||||
const sprite = new Sprite(info.texture);
|
||||
sprite.anchor.set(0.5);
|
||||
sprite.position.set(WORLD_SIZE / 2, WORLD_SIZE / 2);
|
||||
world.content.addChild(sprite);
|
||||
world.render();
|
||||
|
||||
return { destroy: () => world.destroy(), world };
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { GameWorld } from './GameWorld';
|
||||
import { PixelPerfectWorld } from './PixelPerfectWorld';
|
||||
import { getRenderSettings } from './renderSettings';
|
||||
import { NODE_CLICK_RADIUS } from './entities/NodesView';
|
||||
import { rabbitClickRadius } from './entities/RabbitsView';
|
||||
|
||||
/** Must mirror the @media rule in index.css that rotates #root into landscape on a portrait
|
||||
* touchscreen — when that rule is active, the browser paints everything rotated 90°, so a raw
|
||||
* PointerEvent's clientX/clientY (and the container's getBoundingClientRect()) report the
|
||||
* physical, unrotated screen and have to be converted back to the game's own logical space. See
|
||||
* toLocalPoint. (container.clientWidth/clientHeight, used for sizing, are unaffected by the
|
||||
* rotation transform — only paint-space values like clientX/Y and getBoundingClientRect() are.) */
|
||||
const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)';
|
||||
|
||||
type Target = { kind: 'node'; id: string } | { kind: 'rabbit'; id: string };
|
||||
|
||||
/** Owns the pixel-perfect canvas for the game world (see GameWorld/PixelPerfectWorld) and all
|
||||
* pointer input for it — the canvas-rendering replacement for GameMap.tsx's map div. The HUD
|
||||
* around it stays plain DOM (see src/app). */
|
||||
export class GameCanvas {
|
||||
private readonly world = new PixelPerfectWorld();
|
||||
private gameWorld: GameWorld | null = null;
|
||||
private store: GameStore | null = null;
|
||||
private container: HTMLElement | null = null;
|
||||
private isRotated = false;
|
||||
private readonly mql = window.matchMedia(ROTATE_QUERY);
|
||||
|
||||
/** Mounts the canvas into `container`, sizes the world to the container's current (already-
|
||||
* logical, rotation-unaffected) box, and starts the game. Mirrors GameMap.tsx's mount effect. */
|
||||
async mount(container: HTMLElement, store: GameStore): Promise<void> {
|
||||
this.container = container;
|
||||
this.store = store;
|
||||
this.isRotated = this.mql.matches;
|
||||
this.mql.addEventListener('change', this.handleOrientationChange);
|
||||
|
||||
// The game simulates and draws a virtual screen smaller than the container's real pixels —
|
||||
// sprites render at native 1:1 (see textures.ts), and gameplay distances (constants.ts/
|
||||
// rabbits.ts/upgrades.ts) are tuned for this smaller scale. A graphics-layer-only concern:
|
||||
// PixelPerfectWorld's own k upscales the virtual screen back to fill the real container,
|
||||
// however large it actually is — the game/ layer never reads clientWidth itself.
|
||||
const width = Math.round(container.clientWidth / 3);
|
||||
const height = Math.round(container.clientHeight / 3);
|
||||
if (width <= 0 || height <= 0)
|
||||
return;
|
||||
|
||||
const settings = getRenderSettings();
|
||||
await this.world.mount(container, width, height, { antialiasing: settings.antialiasing, noScale: settings.noScale });
|
||||
this.world.app.canvas.style.touchAction = 'none';
|
||||
|
||||
this.gameWorld = new GameWorld();
|
||||
this.gameWorld.setWorldSize(width, height);
|
||||
this.world.content.addChild(this.gameWorld.container);
|
||||
|
||||
store.initialize(width, height);
|
||||
store.start();
|
||||
|
||||
container.addEventListener('pointerdown', this.handlePointerDown);
|
||||
container.addEventListener('pointermove', this.handlePointerMove);
|
||||
container.addEventListener('pointerup', this.handlePointerMove);
|
||||
container.addEventListener('pointercancel', this.handlePointerCancel);
|
||||
window.addEventListener('blur', store.release);
|
||||
window.addEventListener('beforeunload', store.save);
|
||||
|
||||
this.world.app.ticker.add(this.tick);
|
||||
}
|
||||
|
||||
/** Tears down the canvas and every listener — mirrors GameMap.tsx's unmount cleanup. */
|
||||
destroy(): void {
|
||||
this.mql.removeEventListener('change', this.handleOrientationChange);
|
||||
this.world.app.ticker.remove(this.tick);
|
||||
|
||||
if (this.container) {
|
||||
this.container.removeEventListener('pointerdown', this.handlePointerDown);
|
||||
this.container.removeEventListener('pointermove', this.handlePointerMove);
|
||||
this.container.removeEventListener('pointerup', this.handlePointerMove);
|
||||
this.container.removeEventListener('pointercancel', this.handlePointerCancel);
|
||||
}
|
||||
if (this.store) {
|
||||
this.store.stop();
|
||||
window.removeEventListener('blur', this.store.release);
|
||||
window.removeEventListener('beforeunload', this.store.save);
|
||||
}
|
||||
|
||||
this.world.destroy();
|
||||
this.gameWorld = null;
|
||||
this.store = null;
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
private readonly handleOrientationChange = (e: MediaQueryListEvent) => {
|
||||
this.isRotated = e.matches;
|
||||
};
|
||||
|
||||
private readonly tick = () => {
|
||||
if (!this.gameWorld || !this.store)
|
||||
return;
|
||||
this.gameWorld.update(this.store, this.world.app.ticker.deltaMS / 1000);
|
||||
// Bake this frame's world at its fixed logical resolution — see PixelPerfectWorld's doc
|
||||
// comment for why this, rather than drawing world content directly to the screen, is what
|
||||
// keeps it shimmer-free and free of the uneven-pixel-size artifacts a fractional device pixel
|
||||
// ratio would otherwise cause.
|
||||
this.world.render();
|
||||
};
|
||||
|
||||
/** Converts a pointer event's physical screen position into the map's own logical (world) (x,
|
||||
* y) — first undoing the portrait-rotation transform (see ROTATE_QUERY), then the world-sprite's
|
||||
* own scale/letterbox offset (see PixelPerfectWorld.screenToWorld). */
|
||||
private toLocalPoint(clientX: number, clientY: number, rect: DOMRect): { x: number; y: number } {
|
||||
const physicalX = clientX - rect.left;
|
||||
const physicalY = clientY - rect.top;
|
||||
const [screenX, screenY] = this.isRotated ? [physicalY, rect.width - physicalX] : [physicalX, physicalY];
|
||||
return this.world.screenToWorld(screenX, screenY);
|
||||
}
|
||||
|
||||
/** Finds the node or rabbit (if any) under the given logical point, by distance to its center —
|
||||
* the canvas-rendering equivalent of GameMap.tsx's elementFromPoint hit-test. Among overlapping
|
||||
* candidates, picks whichever has the largest y (drawn frontmost — see the zIndex depth-sort in
|
||||
* the entity views). */
|
||||
private targetAt(x: number, y: number): Target | null {
|
||||
const store = this.store!;
|
||||
let best: (Target & { y: number }) | null = null;
|
||||
|
||||
for (const node of store.nodes) {
|
||||
if (Math.hypot(node.x - x, node.y - y) <= NODE_CLICK_RADIUS[node.kind] && (!best || node.y > best.y))
|
||||
best = { kind: 'node', id: node.id, y: node.y };
|
||||
}
|
||||
|
||||
const radius = rabbitClickRadius(store);
|
||||
for (const rabbit of store.rabbits) {
|
||||
if (Math.hypot(rabbit.x - x, rabbit.y - y) <= radius && (!best || rabbit.y > best.y))
|
||||
best = { kind: 'rabbit', id: rabbit.id, y: rabbit.y };
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The character's action is purely a function of the current pointer state — shared by
|
||||
* pointerdown/move/up so panning the cursor across a node mid-drag starts/stops chopping it, and
|
||||
* releasing the button stops it. Mirrors GameMap.tsx's followPointer. */
|
||||
private readonly handlePointerMove = (e: PointerEvent) => {
|
||||
const store = this.store;
|
||||
if (!store)
|
||||
return;
|
||||
|
||||
if ((e.buttons & 1) === 0) {
|
||||
store.release();
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const { x, y } = this.toLocalPoint(e.clientX, e.clientY, rect);
|
||||
const target = this.targetAt(x, y);
|
||||
if (target) {
|
||||
if (target.kind === 'node')
|
||||
store.holdNode(target.id);
|
||||
else
|
||||
store.holdRabbit(target.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// A rabbit can jump out from under an otherwise-still cursor on its own — keep chasing it
|
||||
// until the player points at something else or releases the button (see GameStore).
|
||||
if (store.heldRabbitId)
|
||||
return;
|
||||
|
||||
store.moveTo(x, y);
|
||||
};
|
||||
|
||||
private readonly handlePointerDown = (e: PointerEvent) => {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
this.handlePointerMove(e);
|
||||
};
|
||||
|
||||
private readonly handlePointerCancel = () => {
|
||||
this.store?.release();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Container, Sprite, Texture } from 'pixi.js';
|
||||
import { overlayColorComponentsForHour } from '../game/daynight';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { CharacterView } from './entities/CharacterView';
|
||||
import { DropsView } from './entities/DropsView';
|
||||
import { NodesView } from './entities/NodesView';
|
||||
import { RabbitsView } from './entities/RabbitsView';
|
||||
|
||||
/** Owns every visual layer of the game world (shadows/sprites, hp bars, day-night tint) and
|
||||
* advances them each Pixi tick from the current GameStore state. Deliberately NOT attached to
|
||||
* `app.stage` itself — GameCanvas renders `container` into a fixed-resolution RenderTexture (one
|
||||
* texel per world/logical pixel) and displays that as a single integer-scaled sprite, so no world
|
||||
* content is ever composited at a fractional/subpixel screen position. The HUD (topbar/sidebar/
|
||||
* menu/death screen) is plain DOM, not part of this — see src/app. */
|
||||
export class GameWorld {
|
||||
/** Everything drawn for one frame of the world — render this into a RenderTexture, don't add it
|
||||
* to app.stage directly (see GameCanvas). */
|
||||
readonly container = new Container();
|
||||
|
||||
private readonly entities = new Container();
|
||||
private readonly healthBars = new Container();
|
||||
private readonly overlay = new Sprite(Texture.WHITE);
|
||||
private readonly nodesView: NodesView;
|
||||
private readonly dropsView: DropsView;
|
||||
private readonly rabbitsView: RabbitsView;
|
||||
private readonly characterView: CharacterView;
|
||||
private elapsedSeconds = 0;
|
||||
|
||||
constructor() {
|
||||
this.entities.sortableChildren = true;
|
||||
// Overlay sits above health bars (9999 vs 500 in the original CSS z-indexes), tinting
|
||||
// everything underneath, including them.
|
||||
this.container.addChild(this.entities, this.healthBars, this.overlay);
|
||||
this.overlay.eventMode = 'none';
|
||||
|
||||
this.nodesView = new NodesView(this.entities);
|
||||
this.dropsView = new DropsView(this.entities);
|
||||
this.rabbitsView = new RabbitsView(this.entities, this.healthBars);
|
||||
this.characterView = new CharacterView(this.entities, this.healthBars);
|
||||
}
|
||||
|
||||
/** Sizes the full-screen day/night tint to cover the map — called once, with the fixed
|
||||
* world/logical size (see GameCanvas), not on every canvas resize. */
|
||||
setWorldSize(width: number, height: number): void {
|
||||
this.overlay.width = width;
|
||||
this.overlay.height = height;
|
||||
}
|
||||
|
||||
update(store: GameStore, deltaSeconds: number): void {
|
||||
this.elapsedSeconds += deltaSeconds;
|
||||
this.nodesView.sync(store, this.elapsedSeconds);
|
||||
this.dropsView.sync(store);
|
||||
this.rabbitsView.sync(store, this.elapsedSeconds);
|
||||
this.characterView.sync(store, this.elapsedSeconds);
|
||||
|
||||
const { r, g, b, a } = overlayColorComponentsForHour(store.hour);
|
||||
this.overlay.tint = (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);
|
||||
this.overlay.alpha = a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Application, Container, Graphics, RenderTexture, Sprite, type ApplicationOptions } from 'pixi.js';
|
||||
|
||||
/** Renders `content` (any Pixi Container) into a canvas with zero fractional-pixel scaling
|
||||
* anywhere Pixi/WebGL is involved — the shared mechanics behind GameCanvas (the full game world)
|
||||
* and DebugCharacterPreview (the menu's pixel-grid sanity check), so both use exactly the same
|
||||
* math.
|
||||
*
|
||||
* `content` is rendered once into a RenderTexture sized exactly `worldWidth x worldHeight` — one
|
||||
* texel per world/logical pixel — then displayed as a single sprite scaled up by a whole number
|
||||
* `k`. Both the texture and the outer Application deliberately use `resolution: 1` (i.e.
|
||||
* independent of devicePixelRatio) so `sprite.scale` is always a clean integer: an earlier version
|
||||
* set `resolution: devicePixelRatio` and solved for a `sprite.scale` of `k / resolution` so the
|
||||
* *physical* (backing-store) scale would come out to the integer `k` — this looked correct
|
||||
* (`(k / resolution) * resolution === k` on paper) but empirically was not: WebGL's nearest-
|
||||
* neighbor sampling of a sprite whose own *logical* scale is fractional does not reliably resolve
|
||||
* to uniform per-texel pixel runs, resolution or no — verified by reading the rendered canvas back
|
||||
* pixel-by-pixel (`preserveDrawingBuffer`) and finding uneven run-lengths even when the fractional
|
||||
* scale's product with resolution was exactly an integer. Keeping `sprite.scale` a literal integer
|
||||
* avoids asking WebGL to do that fractional-then-compensated sampling at all.
|
||||
*
|
||||
* The one fractional-scale step this *doesn't* eliminate is the browser's own final compositing of
|
||||
* the canvas's backing store onto the physical screen at a non-integer devicePixelRatio (e.g.
|
||||
* Windows' 125%/150% display scaling) — `image-rendering: pixelated` keeps that nearest-neighbor
|
||||
* rather than smoothed, but nearest-neighbor upscaling by a non-integer ratio is *inherently*
|
||||
* uneven (some source pixels become 1 output pixel, others 2, in whatever pattern the browser's
|
||||
* compositor picks) — no web technology can avoid that for a genuinely fractional display scale.
|
||||
* The goal here is only to make sure *that* unavoidable, display-driven unevenness is the sole
|
||||
* source of any pixel irregularity, rather than compounding it with WebGL sampling of its own. */
|
||||
export interface PixelPerfectOptions {
|
||||
/** Smooths the world texture's upscale to the physical screen instead of the usual crisp,
|
||||
* nearest-neighbor blocky look — see RenderSettings' own doc comment. Off by default. */
|
||||
antialiasing?: boolean;
|
||||
/** Forces the world-to-screen upscale factor `k` (see layout()) to 1, instead of the usual
|
||||
* "biggest whole number that fits the container" — a rendering-diagnostics toggle. Off by
|
||||
* default. */
|
||||
noScale?: boolean;
|
||||
}
|
||||
|
||||
export class PixelPerfectWorld {
|
||||
readonly app = new Application();
|
||||
/** Draw whatever should appear in the world into this container — it is never added to
|
||||
* `app.stage` itself, only rendered into the fixed-resolution texture (see render()). */
|
||||
readonly content = new Container();
|
||||
|
||||
private texture: RenderTexture | null = null;
|
||||
private sprite: Sprite | null = null;
|
||||
/** Fills whatever the world-sprite's own (integer-scaled) bounds *don't* cover — an aspect-
|
||||
* ratio mismatch between the world and the container, or the `noScale` diagnostic forcing k=1,
|
||||
* both leave a margin around it. This has to be a separate, explicitly-bounded shape rather than
|
||||
* just the app's own background color: the world-sprite's texture is transparent everywhere
|
||||
* that isn't an entity (the game's grass ground is a plain CSS background *behind* this
|
||||
* transparent canvas, not part of what Pixi draws — see App.css's `.game-map`), so painting the
|
||||
* *whole* canvas opaque would black out the actual in-world ground too, not just the margin
|
||||
* around it. Drawn as up to 4 rects around the sprite's own rectangle so the two never overlap. */
|
||||
private readonly letterbox = new Graphics();
|
||||
private worldWidth = 0;
|
||||
private worldHeight = 0;
|
||||
private noScale = false;
|
||||
|
||||
/** Creates the Application, sized/resized to fill `container`, and the fixed `worldWidth x
|
||||
* worldHeight` texture `content` renders into. */
|
||||
async mount(
|
||||
container: HTMLElement,
|
||||
worldWidth: number,
|
||||
worldHeight: number,
|
||||
options?: PixelPerfectOptions,
|
||||
appOptions?: Partial<ApplicationOptions>,
|
||||
): Promise<void> {
|
||||
this.worldWidth = worldWidth;
|
||||
this.worldHeight = worldHeight;
|
||||
this.noScale = options?.noScale ?? false;
|
||||
const antialiasing = options?.antialiasing ?? false;
|
||||
|
||||
await this.app.init({
|
||||
resizeTo: container,
|
||||
// Transparent, not opaque black — see the `letterbox` field's own doc comment for why the
|
||||
// black bars are a separate, explicitly-bounded Graphics rather than just the app's own
|
||||
// background color.
|
||||
backgroundAlpha: 0,
|
||||
antialias: antialiasing,
|
||||
roundPixels: true,
|
||||
// Deliberately 1, not devicePixelRatio — see the class doc comment.
|
||||
resolution: 1,
|
||||
autoDensity: true,
|
||||
...appOptions,
|
||||
});
|
||||
this.app.canvas.style.display = 'block';
|
||||
this.app.canvas.style.imageRendering = antialiasing ? 'auto' : 'pixelated';
|
||||
container.appendChild(this.app.canvas);
|
||||
|
||||
// Only the *outer* (world-texture-to-screen) sample mode responds to `antialiasing` — the
|
||||
// world's own bake stays crisp/unfiltered regardless (see textures.ts and GameWorld), since
|
||||
// that's the actual pixel-art data, not something to blur; this only smooths its final,
|
||||
// screen-sized upscale.
|
||||
this.texture = RenderTexture.create({
|
||||
width: worldWidth,
|
||||
height: worldHeight,
|
||||
scaleMode: antialiasing ? 'linear' : 'nearest',
|
||||
antialias: false,
|
||||
resolution: 1,
|
||||
});
|
||||
this.sprite = new Sprite(this.texture);
|
||||
this.app.stage.addChild(this.letterbox, this.sprite);
|
||||
this.layout();
|
||||
this.app.renderer.on('resize', () => this.layout());
|
||||
}
|
||||
|
||||
/** Bakes the current `content` into the world texture. Call after mutating `content` (every
|
||||
* frame for an animated world, once for a static debug preview). */
|
||||
render(): void {
|
||||
if (!this.texture)
|
||||
return;
|
||||
this.app.renderer.render({ container: this.content, target: this.texture, clearColor: 'transparent' });
|
||||
}
|
||||
|
||||
/** Converts a point in the canvas's own logical (CSS-pixel) space — e.g. a pointer event's
|
||||
* position relative to the canvas's bounding rect — into world/logical (x, y). */
|
||||
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
|
||||
if (!this.sprite)
|
||||
return { x: screenX, y: screenY };
|
||||
return { x: (screenX - this.sprite.x) / this.sprite.scale.x, y: (screenY - this.sprite.y) / this.sprite.scale.y };
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.texture?.destroy(true);
|
||||
this.app.destroy(true, { children: true, texture: false });
|
||||
this.texture = null;
|
||||
this.sprite = null;
|
||||
}
|
||||
|
||||
/** Recomputes the world-sprite's integer scale `k` and its (rounded, letterboxing) position
|
||||
* whenever the canvas's own on-screen size changes — the fixed-size world texture itself is
|
||||
* never touched. `k` is floored and never smaller than 1, so a canvas larger than the world
|
||||
* upscales it in clean whole-pixel steps, and a canvas smaller than the world just lets it
|
||||
* overflow (expected to be clipped by an `overflow: hidden` ancestor) — unless `noScale` forces
|
||||
* it to exactly 1 regardless of how much bigger the container is. */
|
||||
private layout(): void {
|
||||
if (!this.sprite || this.worldWidth <= 0 || this.worldHeight <= 0)
|
||||
return;
|
||||
const screen = this.app.screen;
|
||||
const k = this.noScale
|
||||
? 1
|
||||
: Math.max(1, Math.floor(Math.min(screen.width / this.worldWidth, screen.height / this.worldHeight)));
|
||||
|
||||
this.sprite.scale.set(k);
|
||||
const x = Math.round((screen.width - this.worldWidth * k) / 2);
|
||||
const y = Math.round((screen.height - this.worldHeight * k) / 2);
|
||||
this.sprite.position.set(x, y);
|
||||
|
||||
const right = x + this.worldWidth * k;
|
||||
const bottom = y + this.worldHeight * k;
|
||||
this.letterbox.clear();
|
||||
if (y > 0)
|
||||
this.letterbox.rect(0, 0, screen.width, y).fill(0x000000); // top
|
||||
if (screen.height - bottom > 0)
|
||||
this.letterbox.rect(0, bottom, screen.width, screen.height - bottom).fill(0x000000); // bottom
|
||||
if (x > 0)
|
||||
this.letterbox.rect(0, y, x, this.worldHeight * k).fill(0x000000); // left
|
||||
if (screen.width - right > 0)
|
||||
this.letterbox.rect(right, y, screen.width - right, this.worldHeight * k).fill(0x000000); // right
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Ticker-driven equivalents of App.css's `walk-bob` and `chop-shake` keyframe animations. Both
|
||||
* read a single continuously-running clock (seconds since the world started) rather than timing
|
||||
* from when the animation "started" (as CSS restarts it on class-toggle) — at these durations
|
||||
* (0.12-0.3s) the discontinuity from not resetting is imperceptible, and not tracking a per-entity
|
||||
* start time keeps every caller a one-line lookup. */
|
||||
|
||||
const WALK_BOB_DURATION = 0.3;
|
||||
const WALK_BOB_OFFSET_Y = -3;
|
||||
|
||||
/** Mirrors `@keyframes walk-bob` with `steps(2)`: holds at 0, then jumps to -3px for the second
|
||||
* half of each cycle. */
|
||||
export function walkBobOffsetY(elapsedSeconds: number): number {
|
||||
const phase = elapsedSeconds % WALK_BOB_DURATION;
|
||||
return phase < WALK_BOB_DURATION / 2 ? 0 : WALK_BOB_OFFSET_Y;
|
||||
}
|
||||
|
||||
export interface ShakeOffset {
|
||||
x: number;
|
||||
y: number;
|
||||
/** Radians. */
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
const CHOP_SHAKE_DURATION = 0.12;
|
||||
|
||||
// Mirrors `@keyframes chop-shake`'s five stops (0/25/50/75/100%); rotation in degrees here,
|
||||
// converted to radians in chopShakeOffset.
|
||||
const CHOP_KEYFRAMES: ShakeOffset[] = [
|
||||
{ x: 0, y: 0, rotation: 0 },
|
||||
{ x: -2, y: 1, rotation: -6 },
|
||||
{ x: 1, y: -2, rotation: 5 },
|
||||
{ x: -1, y: 1, rotation: -4 },
|
||||
{ x: 0, y: 0, rotation: 0 },
|
||||
];
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/** Mirrors `@keyframes chop-shake` (default ease timing, approximated as linear interpolation
|
||||
* between its stops — imperceptible at a 0.12s cycle). */
|
||||
export function chopShakeOffset(elapsedSeconds: number): ShakeOffset {
|
||||
const phase = ((elapsedSeconds % CHOP_SHAKE_DURATION) / CHOP_SHAKE_DURATION) * 4;
|
||||
const index = Math.floor(phase);
|
||||
const frac = phase - index;
|
||||
const a = CHOP_KEYFRAMES[index];
|
||||
const b = CHOP_KEYFRAMES[index + 1];
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * frac,
|
||||
y: a.y + (b.y - a.y) * frac,
|
||||
rotation: (a.rotation + (b.rotation - a.rotation) * frac) * DEG_TO_RAD,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Container, Sprite } from 'pixi.js';
|
||||
import type { GameStore } from '../../game/GameStore';
|
||||
import { CHARACTER_SPRITE } from '../../game/sprites';
|
||||
import { chopShakeOffset, walkBobOffsetY } from '../animations';
|
||||
import { createHealthBar, setHealthBarPosition, updateHealthBar } from '../healthBar';
|
||||
import { createShadow } from '../shadow';
|
||||
import { getSpriteTexture } from '../textures';
|
||||
|
||||
const SHADOW_OFFSET_Y = 11;
|
||||
const SHADOW_RADIUS_X = 8;
|
||||
const SHADOW_RADIUS_Y = 4;
|
||||
/** How far above the character's own position the hp bar floats. */
|
||||
const HEALTH_BAR_OFFSET_Y = 10;
|
||||
|
||||
/** Renders the player character: shadow, sprite (with walk-bob/hit-shake), and an hp bar shown
|
||||
* only while damaged — mirrors CharacterEntity.tsx. */
|
||||
export class CharacterView {
|
||||
private readonly shadow = createShadow(SHADOW_RADIUS_X, SHADOW_RADIUS_Y);
|
||||
private readonly sprite: Sprite;
|
||||
private readonly healthBar = createHealthBar();
|
||||
|
||||
constructor(entities: Container, healthBars: Container) {
|
||||
const info = getSpriteTexture(CHARACTER_SPRITE);
|
||||
this.sprite = new Sprite(info.texture);
|
||||
this.sprite.anchor.set(0.5);
|
||||
|
||||
entities.addChild(this.shadow, this.sprite);
|
||||
healthBars.addChild(this.healthBar);
|
||||
}
|
||||
|
||||
sync(store: GameStore, elapsedSeconds: number): void {
|
||||
const { x, y } = store.character;
|
||||
|
||||
this.shadow.position.set(x, y + SHADOW_OFFSET_Y);
|
||||
this.shadow.zIndex = y - 1;
|
||||
|
||||
this.sprite.zIndex = y;
|
||||
if (store.characterHitFlashTimer > 0) {
|
||||
const shake = chopShakeOffset(elapsedSeconds);
|
||||
this.sprite.position.set(x + shake.x, y + shake.y);
|
||||
this.sprite.rotation = shake.rotation;
|
||||
} else {
|
||||
const bobY = store.isMoving ? walkBobOffsetY(elapsedSeconds) : 0;
|
||||
this.sprite.position.set(x, y + bobY);
|
||||
this.sprite.rotation = 0;
|
||||
}
|
||||
|
||||
const damaged = store.characterHp < store.characterMaxHp;
|
||||
this.healthBar.visible = damaged;
|
||||
if (damaged) {
|
||||
updateHealthBar(this.healthBar, store.characterHp / store.characterMaxHp);
|
||||
setHealthBarPosition(this.healthBar, x, y - HEALTH_BAR_OFFSET_Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Container, Sprite } from 'pixi.js';
|
||||
import type { GameStore } from '../../game/GameStore';
|
||||
import type { ResourceType } from '../../game/types';
|
||||
import { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE, type PixelGrid } from '../../game/sprites';
|
||||
import { createShadow } from '../shadow';
|
||||
import { getSpriteTexture } from '../textures';
|
||||
|
||||
const DROP_SPRITES: Record<ResourceType, PixelGrid> = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE };
|
||||
// .entity.log-shadow: margin-top 8. .entity.twig-shadow / .entity.meat-shadow: margin-top 7.
|
||||
const SHADOW_OFFSET_Y: Record<ResourceType, number> = { log: 8, twig: 7, meat: 7 };
|
||||
const SHADOW_RADII: Record<ResourceType, { x: number; y: number }> = {
|
||||
log: { x: 10, y: 5 },
|
||||
twig: { x: 9, y: 4.5 },
|
||||
meat: { x: 9, y: 4.5 },
|
||||
};
|
||||
|
||||
interface DropVisual {
|
||||
shadow: ReturnType<typeof createShadow>;
|
||||
sprite: Sprite;
|
||||
}
|
||||
|
||||
/** Renders every resource drop in flight/landed, syncing to store.resourceDrops each frame
|
||||
* (mirrors ResourceDropEntity.tsx). Not interactive — drops have no click handling. */
|
||||
export class DropsView {
|
||||
private readonly visuals = new Map<string, DropVisual>();
|
||||
private readonly entities: Container;
|
||||
|
||||
constructor(entities: Container) {
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
sync(store: GameStore): void {
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const drop of store.resourceDrops) {
|
||||
seen.add(drop.id);
|
||||
let visual = this.visuals.get(drop.id);
|
||||
if (!visual) {
|
||||
visual = this.create(drop.resource);
|
||||
this.visuals.set(drop.id, visual);
|
||||
}
|
||||
|
||||
visual.shadow.position.set(drop.x, drop.y + SHADOW_OFFSET_Y[drop.resource]);
|
||||
visual.shadow.zIndex = drop.y - 1;
|
||||
|
||||
const drawY = drop.y - drop.z;
|
||||
visual.sprite.position.set(drop.x, drawY);
|
||||
visual.sprite.zIndex = drawY;
|
||||
}
|
||||
|
||||
for (const [id, visual] of this.visuals) {
|
||||
if (!seen.has(id)) {
|
||||
this.entities.removeChild(visual.shadow, visual.sprite);
|
||||
visual.shadow.destroy();
|
||||
visual.sprite.destroy();
|
||||
this.visuals.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private create(resource: ResourceType): DropVisual {
|
||||
const radii = SHADOW_RADII[resource];
|
||||
const shadow = createShadow(radii.x, radii.y);
|
||||
|
||||
const info = getSpriteTexture(DROP_SPRITES[resource]);
|
||||
const sprite = new Sprite(info.texture);
|
||||
sprite.anchor.set(0.5);
|
||||
|
||||
this.entities.addChild(shadow, sprite);
|
||||
return { shadow, sprite };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Container, Sprite } from 'pixi.js';
|
||||
import type { GameStore } from '../../game/GameStore';
|
||||
import type { NodeKind } from '../../game/types';
|
||||
import { BUSH_SPRITE, TREE_SPRITE, type PixelGrid } from '../../game/sprites';
|
||||
import { chopShakeOffset } from '../animations';
|
||||
import { createShadow } from '../shadow';
|
||||
import { getSpriteTexture } from '../textures';
|
||||
|
||||
const NODE_SPRITES: Record<NodeKind, PixelGrid> = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
|
||||
const SHADOW_OFFSET: Record<NodeKind, { x: number; y: number; radiusX: number; radiusY: number }> = {
|
||||
tree: { x: -3, y: 7, radiusX: 8, radiusY: 4 },
|
||||
bush: { x: -2, y: 5, radiusX: 7, radiusY: 3 },
|
||||
};
|
||||
// Expands the clickable area beyond the sprite's own box — approximated here as a circle for
|
||||
// hit-testing (see GameCanvas).
|
||||
export const NODE_CLICK_RADIUS: Record<NodeKind, number> = {
|
||||
tree: 11,
|
||||
bush: 9,
|
||||
};
|
||||
|
||||
interface NodeVisual {
|
||||
shadow: ReturnType<typeof createShadow>;
|
||||
sprite: Sprite;
|
||||
}
|
||||
|
||||
/** Renders every tree/bush, syncing to store.nodes each frame (mirrors NodeEntity.tsx, one per
|
||||
* live node) — entities are added/removed as nodes are chopped down or grown in. */
|
||||
export class NodesView {
|
||||
private readonly visuals = new Map<string, NodeVisual>();
|
||||
private readonly entities: Container;
|
||||
|
||||
constructor(entities: Container) {
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
sync(store: GameStore, elapsedSeconds: number): void {
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const node of store.nodes) {
|
||||
seen.add(node.id);
|
||||
let visual = this.visuals.get(node.id);
|
||||
if (!visual) {
|
||||
visual = this.create(node.kind);
|
||||
this.visuals.set(node.id, visual);
|
||||
}
|
||||
|
||||
const offset = SHADOW_OFFSET[node.kind];
|
||||
visual.shadow.position.set(node.x + offset.x, node.y + offset.y);
|
||||
visual.shadow.zIndex = node.y - 1;
|
||||
|
||||
visual.sprite.zIndex = node.y;
|
||||
const isChopping = node.id === store.heldNodeId && store.isChopping;
|
||||
if (isChopping) {
|
||||
const shake = chopShakeOffset(elapsedSeconds);
|
||||
visual.sprite.position.set(node.x + shake.x, node.y + shake.y);
|
||||
visual.sprite.rotation = shake.rotation;
|
||||
} else {
|
||||
visual.sprite.position.set(node.x, node.y);
|
||||
visual.sprite.rotation = 0;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, visual] of this.visuals) {
|
||||
if (!seen.has(id)) {
|
||||
this.entities.removeChild(visual.shadow, visual.sprite);
|
||||
visual.shadow.destroy();
|
||||
visual.sprite.destroy();
|
||||
this.visuals.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private create(kind: NodeKind): NodeVisual {
|
||||
const offset = SHADOW_OFFSET[kind];
|
||||
const shadow = createShadow(offset.radiusX, offset.radiusY);
|
||||
|
||||
const info = getSpriteTexture(NODE_SPRITES[kind]);
|
||||
const sprite = new Sprite(info.texture);
|
||||
sprite.anchor.set(0.5);
|
||||
|
||||
this.entities.addChild(shadow, sprite);
|
||||
const visual: NodeVisual = { shadow, sprite };
|
||||
return visual;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Container, Graphics, Sprite } from 'pixi.js';
|
||||
import type { GameStore } from '../../game/GameStore';
|
||||
import { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../../game/sprites';
|
||||
import { chopShakeOffset } from '../animations';
|
||||
import { createHealthBar, setHealthBarPosition, updateHealthBar } from '../healthBar';
|
||||
import { drawShadow } from '../shadow';
|
||||
import { getSpriteTexture } from '../textures';
|
||||
|
||||
/** The rabbit sprite's own native width (its grid's col count) — the "nominal" size shadow/hp-bar/
|
||||
* click geometry below is expressed relative to; blood moon doubles it via a real sprite.scale
|
||||
* (see sync()), a deliberate visual size cue rather than a rendering-resolution artifact. */
|
||||
const RABBIT_SIZE = 14;
|
||||
const RABBIT_GAP = 3;
|
||||
|
||||
/** Click radius (px) for hit-testing a rabbit at its current size — used by GameCanvas. */
|
||||
export function rabbitClickRadius(store: GameStore): number {
|
||||
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
|
||||
return size / 2 + RABBIT_GAP;
|
||||
}
|
||||
|
||||
interface RabbitVisual {
|
||||
shadow: Graphics;
|
||||
sprite: Sprite;
|
||||
healthBar: Graphics;
|
||||
/** Size (px) the shadow was last drawn at, so it's only redrawn on an actual day<->blood-moon
|
||||
* size change rather than every frame. */
|
||||
shadowSize: number;
|
||||
/** Whether the sprite is currently showing the night (red) palette. */
|
||||
isNightSprite: boolean;
|
||||
}
|
||||
|
||||
/** Renders every rabbit, syncing to store.rabbits each frame (mirrors RabbitEntity.tsx) —
|
||||
* entities are added/removed as rabbits are caught or grown in, and swap sprite/size for
|
||||
* night/blood-moon. */
|
||||
export class RabbitsView {
|
||||
private readonly visuals = new Map<string, RabbitVisual>();
|
||||
private readonly entities: Container;
|
||||
private readonly healthBars: Container;
|
||||
|
||||
constructor(entities: Container, healthBars: Container) {
|
||||
this.entities = entities;
|
||||
this.healthBars = healthBars;
|
||||
}
|
||||
|
||||
sync(store: GameStore, elapsedSeconds: number): void {
|
||||
const seen = new Set<string>();
|
||||
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
|
||||
|
||||
for (const rabbit of store.rabbits) {
|
||||
seen.add(rabbit.id);
|
||||
let visual = this.visuals.get(rabbit.id);
|
||||
if (!visual) {
|
||||
visual = this.create();
|
||||
this.visuals.set(rabbit.id, visual);
|
||||
}
|
||||
|
||||
if (visual.shadowSize !== size) {
|
||||
// .entity.rabbit-shadow: width size, height size/2, margin-top size*0.6.
|
||||
drawShadow(visual.shadow, size / 2, size / 4);
|
||||
visual.shadowSize = size;
|
||||
}
|
||||
visual.shadow.position.set(rabbit.x, rabbit.y + size * 0.6);
|
||||
visual.shadow.zIndex = rabbit.y - 1;
|
||||
|
||||
const info = getSpriteTexture(store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE);
|
||||
if (visual.isNightSprite !== store.isNight) {
|
||||
visual.sprite.texture = info.texture;
|
||||
visual.isNightSprite = store.isNight;
|
||||
}
|
||||
// Only the blood-moon ×2 ever scales this sprite — everything else renders at native 1:1.
|
||||
visual.sprite.scale.set(store.isBloodMoon ? 2 : 1);
|
||||
|
||||
const drawY = rabbit.y - rabbit.z;
|
||||
visual.sprite.zIndex = drawY;
|
||||
if (rabbit.hitFlashTimer > 0) {
|
||||
const shake = chopShakeOffset(elapsedSeconds);
|
||||
visual.sprite.position.set(rabbit.x + shake.x, drawY + shake.y);
|
||||
visual.sprite.rotation = shake.rotation;
|
||||
} else {
|
||||
visual.sprite.position.set(rabbit.x, drawY);
|
||||
visual.sprite.rotation = 0;
|
||||
}
|
||||
|
||||
const damaged = rabbit.hp < store.rabbitMaxHp;
|
||||
visual.healthBar.visible = damaged;
|
||||
if (damaged) {
|
||||
updateHealthBar(visual.healthBar, rabbit.hp / store.rabbitMaxHp);
|
||||
setHealthBarPosition(visual.healthBar, rabbit.x, drawY - size * 0.5 - RABBIT_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, visual] of this.visuals) {
|
||||
if (!seen.has(id)) {
|
||||
this.entities.removeChild(visual.shadow, visual.sprite);
|
||||
this.healthBars.removeChild(visual.healthBar);
|
||||
visual.shadow.destroy();
|
||||
visual.sprite.destroy();
|
||||
visual.healthBar.destroy();
|
||||
this.visuals.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private create(): RabbitVisual {
|
||||
const shadow = new Graphics();
|
||||
drawShadow(shadow, RABBIT_SIZE / 2, RABBIT_SIZE / 4);
|
||||
|
||||
const info = getSpriteTexture(RABBIT_SPRITE);
|
||||
const sprite = new Sprite(info.texture);
|
||||
sprite.anchor.set(0.5);
|
||||
|
||||
const healthBar = createHealthBar();
|
||||
|
||||
this.entities.addChild(shadow, sprite);
|
||||
this.healthBars.addChild(healthBar);
|
||||
|
||||
return { shadow, sprite, healthBar, shadowSize: RABBIT_SIZE, isNightSprite: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
|
||||
/** Matches `.health-bar`/`.health-bar__fill` in App.css. */
|
||||
const WIDTH = 28;
|
||||
const HEIGHT = 5;
|
||||
const BORDER_WIDTH = 1;
|
||||
// The fill sits inside the border's own inner edge, not flush with the bar's outer bounds — Pixi
|
||||
// centers a stroke on its path by default (half the stroke width paints inward), so a fill drawn
|
||||
// flush to (0,0)-(WIDTH,HEIGHT) would paint over that inward half on the top/bottom/left edges,
|
||||
// visibly overlapping the border. The original CSS version didn't have this problem: `.health-
|
||||
// bar__fill` was a child div sized to the *parent's content box*, which excludes its border by
|
||||
// definition.
|
||||
const INNER_WIDTH = WIDTH - BORDER_WIDTH * 2;
|
||||
const INNER_HEIGHT = HEIGHT - BORDER_WIDTH * 2;
|
||||
|
||||
export function createHealthBar(): Graphics {
|
||||
return new Graphics();
|
||||
}
|
||||
|
||||
/** Redraws a health bar's background/border/fill for the given hp ratio (0-1). Position (the
|
||||
* bottom-center anchor CSS did via `transform: translate(-50%, -100%)`) is the caller's job —
|
||||
* see setHealthBarPosition. */
|
||||
export function updateHealthBar(g: Graphics, ratio: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, ratio));
|
||||
g.clear();
|
||||
g.rect(0, 0, WIDTH, HEIGHT)
|
||||
.fill({ color: 0x000000, alpha: 0.5 })
|
||||
.stroke({ width: BORDER_WIDTH, color: 0x1b1611 });
|
||||
if (clamped > 0)
|
||||
g.rect(BORDER_WIDTH, BORDER_WIDTH, INNER_WIDTH * clamped, INNER_HEIGHT).fill(0x4caf50);
|
||||
}
|
||||
|
||||
/** Positions a health bar so (x, yAboveSprite) is its bottom-center, same anchor as the original
|
||||
* CSS transform. Rounded to the nearest whole world-pixel: a vector shape rasterized (without
|
||||
* antialiasing — see PixelPerfectWorld) at a fractional position can flip a full pixel row in or
|
||||
* out depending on exactly where the fraction falls, and `yAboveSprite` tracks a jumping rabbit's
|
||||
* continuously-changing height — left unrounded, the bar's fill visibly "jumps" a pixel in height
|
||||
* frame to frame for as long as the jump lasts (most noticeable there specifically, since the
|
||||
* position — and so the sub-pixel fraction — is otherwise constant while idle). */
|
||||
export function setHealthBarPosition(g: Graphics, x: number, yAboveSprite: number): void {
|
||||
g.position.set(Math.round(x - WIDTH / 2), Math.round(yAboveSprite - HEIGHT));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const STORAGE_KEY = 'forest-render-settings-v1';
|
||||
|
||||
export interface RenderSettings {
|
||||
/** Smooths the world texture's upscale to the physical screen (linear sampling + real
|
||||
* antialiasing + the browser's own smoothed compositing) instead of the game's normal crisp,
|
||||
* blocky nearest-neighbor look. Purely a display toggle — the world is still baked at its own
|
||||
* fixed resolution with flat, unfiltered pixel-art textures either way (see
|
||||
* PixelPerfectWorld/textures.ts); this only affects the final screen-sized draw. */
|
||||
antialiasing: boolean;
|
||||
/** Forces the world-to-screen upscale factor `k` to 1 instead of the usual "biggest whole
|
||||
* number that fits the container" — a rendering-diagnostics toggle, not a gameplay one (the
|
||||
* world/character/upgrade sizes are unaffected either way). */
|
||||
noScale: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: RenderSettings = { antialiasing: false, noScale: false };
|
||||
|
||||
function isValid(value: unknown): value is Partial<RenderSettings> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function load(): RenderSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw)
|
||||
return { ...DEFAULTS };
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isValid(parsed))
|
||||
return { ...DEFAULTS };
|
||||
return {
|
||||
antialiasing: typeof parsed.antialiasing === 'boolean' ? parsed.antialiasing : DEFAULTS.antialiasing,
|
||||
noScale: typeof parsed.noScale === 'boolean' ? parsed.noScale : DEFAULTS.noScale,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
function persist(settings: RenderSettings): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// Ignore — e.g. private browsing mode or quota exceeded.
|
||||
}
|
||||
}
|
||||
|
||||
let current = load();
|
||||
|
||||
export function getRenderSettings(): RenderSettings {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setRenderSettings(patch: Partial<RenderSettings>): RenderSettings {
|
||||
current = { ...current, ...patch };
|
||||
persist(current);
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
|
||||
/** A soft ground ellipse under an entity, matching the `.entity.*-shadow` rules in App.css. */
|
||||
export function createShadow(radiusX: number, radiusY: number): Graphics {
|
||||
const g = new Graphics();
|
||||
drawShadow(g, radiusX, radiusY);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Redraws a shadow at a new size — only rabbits need this (they double in size during a blood
|
||||
* moon), everything else's shadow is drawn once at creation and never resized. */
|
||||
export function drawShadow(g: Graphics, radiusX: number, radiusY: number): void {
|
||||
g.clear();
|
||||
g.ellipse(0, 0, radiusX, radiusY).fill({ color: 0x000000, alpha: 0.35 });
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BufferImageSource, Texture } from 'pixi.js';
|
||||
import type { PixelGrid } from '../game/sprites';
|
||||
|
||||
export interface SpriteTextureInfo {
|
||||
texture: Texture;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
// PixelGrid constants are module-level singletons in game/sprites.ts, so a WeakMap keyed on the
|
||||
// grid reference caches one baked texture per sprite for the page's whole lifetime. Safe to share
|
||||
// across every Application/renderer the page ever creates (see the doc comment below) — unlike an
|
||||
// RenderTexture baked via `renderer.generateTexture()` (a GPU-side render target tied to whichever
|
||||
// renderer produced it, invalidated when that renderer is destroyed — see GameCanvas.destroy()),
|
||||
// a texture built from a plain CPU-side buffer has no such binding: each renderer independently
|
||||
// uploads its own GPU copy from the same buffer on first use.
|
||||
const cache = new WeakMap<PixelGrid, SpriteTextureInfo>();
|
||||
|
||||
function parseHexColor(hex: string): [r: number, g: number, b: number] {
|
||||
const n = Number.parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
/** Bakes a PixelGrid (a per-cell CSS-color grid, see game/sprites.ts) into a texture built
|
||||
* directly from a raw RGBA buffer — one call, one texel per cell, deterministic `cols x rows`
|
||||
* dimensions. Every sprite is drawn at this native size (no per-sprite scale.set(...) — a plain
|
||||
* `new Sprite(info.texture)` renders at literal 1:1, one texel per world-pixel), so a rabbit's
|
||||
* pixel and a tree's pixel are physically the same size; entity views scale up only for the rare
|
||||
* deliberate exception (e.g. a blood-moon rabbit's ×2 — see RabbitsView.sync()).
|
||||
*
|
||||
* Deliberately NOT `Graphics.rect(x,y,1,1).fill(color)` per cell followed by
|
||||
* `renderer.generateTexture()`, which has two sharp edges: (1) without an explicit `frame`,
|
||||
* `generateTexture` crops to the *drawn content's bounding box*, not the full grid — a sprite
|
||||
* with a fully-transparent edge row/column (common; several of ours have one) bakes a texture
|
||||
* smaller than `cols x rows`, silently mismatching code that assumes the texture *is* `cols x
|
||||
* rows` (as sizing code necessarily must, native or not); and (2) `generateTexture`'s
|
||||
* `resolution` defaults to the *renderer's* resolution, so the same grid bakes to a different
|
||||
* texel size on a HiDPI display, which — combined with (1) — is exactly the kind of "some pixel
|
||||
* rows are a different height than others" artifact this class exists to rule out. A raw buffer
|
||||
* has neither problem: its dimensions are exactly `cols x rows` by construction, and it carries
|
||||
* no renderer/resolution dependency of its own (`resolution: 1` here just labels the buffer's own
|
||||
* texel grid — nothing display-related reads it). It also can't pick up the antialiased edge
|
||||
* bleed `Graphics` fills can have between adjacent same-or-different-colored rects — each texel
|
||||
* is one flat byte4, no rasterization involved. */
|
||||
export function getSpriteTexture(grid: PixelGrid): SpriteTextureInfo {
|
||||
const cached = cache.get(grid);
|
||||
if (cached)
|
||||
return cached;
|
||||
|
||||
const rows = grid.length;
|
||||
const cols = grid[0]?.length ?? 0;
|
||||
const data = new Uint8Array(cols * rows * 4);
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const color = grid[y][x];
|
||||
if (!color)
|
||||
continue;
|
||||
const [r, g, b] = parseHexColor(color);
|
||||
const i = (y * cols + x) * 4;
|
||||
data[i] = r;
|
||||
data[i + 1] = g;
|
||||
data[i + 2] = b;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
const texture = new Texture({
|
||||
source: new BufferImageSource({
|
||||
resource: data,
|
||||
width: cols,
|
||||
height: rows,
|
||||
format: 'rgba8unorm',
|
||||
scaleMode: 'nearest',
|
||||
resolution: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const info: SpriteTextureInfo = { texture, cols, rows };
|
||||
cache.set(grid, info);
|
||||
return info;
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** package.json's "version" field, injected at build/dev-server-start time via vite.config.ts's
|
||||
* `define` — a plain compile-time-replaced global, not import.meta.env, so no VITE_ prefix. */
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** GameAnalytics game key — see .env.example. Analytics is skipped entirely (NullAnalyticsProvider)
|
||||
* when this or VITE_GAMEANALYTICS_GAME_SECRET is unset. */
|
||||
readonly VITE_GAMEANALYTICS_GAME_KEY?: string;
|
||||
readonly VITE_GAMEANALYTICS_GAME_SECRET?: string;
|
||||
}
|
||||
+9
-1
@@ -7,6 +7,15 @@
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"typeRoots": ["./typings"],
|
||||
// The "gameanalytics" package's own shipped .d.ts doesn't compile under strict mode (invalid
|
||||
// `module` keyword usage, inconsistent export modifiers — see typings/gameanalytics.d.ts's
|
||||
// header for the full story) and typeRoots alone doesn't override a package's own real types,
|
||||
// only add extra global ones — this path mapping is what actually redirects every import of
|
||||
// "gameanalytics" to our corrected local file instead.
|
||||
"paths": {
|
||||
"gameanalytics": ["./typings/gameanalytics.d.ts"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
@@ -14,7 +23,6 @@
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
|
||||
Vendored
+85
@@ -0,0 +1,85 @@
|
||||
/** The npm package's own shipped types (node_modules/gameanalytics/dist/GameAnalytics.d.ts) don't
|
||||
* compile under strict mode: `module gameanalytics { ... }` (repeated for every nested namespace)
|
||||
* should be `namespace gameanalytics { ... }` (TS1540), and its export modifiers are inconsistent
|
||||
* across the merged `gameanalytics` declaration, which TS rejects outright (TS2395) — so the
|
||||
* declared `GameAnalytics` member becomes unreachable even in the shipped file's own eyes
|
||||
* (TS2339). This file replaces those types entirely (an ambient `declare module "gameanalytics"`
|
||||
* shadows the package's own types for any import of that specifier).
|
||||
*
|
||||
* It also fixes a real shape mismatch, not just a syntax one: dist/GameAnalytics.esm.js — the
|
||||
* build "gameanalytics"'s package.json "exports" actually resolves to for `import` — runs
|
||||
* `gameanalytics.GameAnalytics.init()` at load and rebinds its own top-level `GameAnalytics` to
|
||||
* `gaCommand` before exporting, i.e. `export default GameAnalytics` ends up exporting that
|
||||
* *command-queue function*, not the class. The class with real, individually-typed methods only
|
||||
* exists on the separate named export `gameanalytics.GameAnalytics`. Concretely:
|
||||
*
|
||||
* import GameAnalytics from 'gameanalytics'; // (command: string, ...args) => void
|
||||
* GameAnalytics('initialize', gameKey, gameSecret);
|
||||
*
|
||||
* import { gameanalytics } from 'gameanalytics'; // the real class, real methods
|
||||
* gameanalytics.GameAnalytics.initialize(gameKey, gameSecret);
|
||||
*/
|
||||
declare module 'gameanalytics' {
|
||||
export enum EGAErrorSeverity { Undefined = 0, Debug = 1, Info = 2, Warning = 3, Error = 4, Critical = 5 }
|
||||
export enum EGAProgressionStatus { Undefined = 0, Start = 1, Complete = 2, Fail = 3 }
|
||||
export enum EGAResourceFlowType { Undefined = 0, Source = 1, Sink = 2 }
|
||||
export enum EGAAdAction { Undefined = 0, Clicked = 1, Show = 2, FailedShow = 3, RewardReceived = 4 }
|
||||
export enum EGAAdError { Undefined = 0, Unknown = 1, Offline = 2, NoFill = 3, InternalError = 4, InvalidRequest = 5, UnableToPrecache = 6 }
|
||||
export enum EGAAdType { Undefined = 0, Video = 1, RewardedVideo = 2, Playable = 3, Interstitial = 4, OfferWall = 5, Banner = 6 }
|
||||
|
||||
export namespace gameanalytics {
|
||||
export class GameAnalytics {
|
||||
static init(): void;
|
||||
static configureAvailableCustomDimensions01(customDimensions?: string[]): void;
|
||||
static configureAvailableCustomDimensions02(customDimensions?: string[]): void;
|
||||
static configureAvailableCustomDimensions03(customDimensions?: string[]): void;
|
||||
static configureAvailableResourceCurrencies(resourceCurrencies?: string[]): void;
|
||||
static configureAvailableResourceItemTypes(resourceItemTypes?: string[]): void;
|
||||
static configureBuild(build?: string): void;
|
||||
static configureSdkGameEngineVersion(sdkGameEngineVersion?: string): void;
|
||||
static configureGameEngineVersion(gameEngineVersion?: string): void;
|
||||
static configureUserId(uId?: string): void;
|
||||
static setExtUserId(uId?: string): void;
|
||||
static getExtUserId(): string;
|
||||
static getUserId(): string;
|
||||
static initialize(gameKey?: string, gameSecret?: string): void;
|
||||
static addBusinessEvent(currency?: string, amount?: number, itemType?: string, itemId?: string, cartType?: string, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addResourceEvent(flowType?: EGAResourceFlowType, currency?: string, amount?: number, itemType?: string, itemId?: string, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addProgressionEvent(progressionStatus?: EGAProgressionStatus, progression01?: string, progression02?: string, progression03?: string, score?: number, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addDesignEvent(eventId: string, value?: number, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addErrorEvent(severity?: EGAErrorSeverity, message?: string, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addAdEventWithNoAdReason(adAction?: EGAAdAction, adType?: EGAAdType, adSdkName?: string, adPlacement?: string, noAdReason?: EGAAdError, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addAdEventWithDuration(adAction?: EGAAdAction, adType?: EGAAdType, adSdkName?: string, adPlacement?: string, duration?: number, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addAdEvent(adAction?: EGAAdAction, adType?: EGAAdType, adSdkName?: string, adPlacement?: string, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static setEnabledInfoLog(flag?: boolean): void;
|
||||
static setEnabledVerboseLog(flag?: boolean): void;
|
||||
static setEnabledManualSessionHandling(flag?: boolean): void;
|
||||
static setEnabledEventSubmission(flag?: boolean): void;
|
||||
static enableHealthEvent(flag?: boolean): void;
|
||||
static setCustomDimension01(dimension?: string): void;
|
||||
static setCustomDimension02(dimension?: string): void;
|
||||
static setCustomDimension03(dimension?: string): void;
|
||||
static setGlobalCustomEventFields(customFields?: Record<string, unknown>): void;
|
||||
static setEventProcessInterval(intervalInSeconds: number): void;
|
||||
static startSession(): void;
|
||||
static endSession(): void;
|
||||
static onStop(): void;
|
||||
static onResume(): void;
|
||||
static getRemoteConfigsValueAsString(key: string, defaultValue?: string): string;
|
||||
static getRemoteConfigsValueAsJSON(key: string, defaultValue?: unknown): unknown;
|
||||
static isRemoteConfigsReady(): boolean;
|
||||
static addRemoteConfigsListener(listener: { onRemoteConfigsUpdated: () => void }): void;
|
||||
static removeRemoteConfigsListener(listener: { onRemoteConfigsUpdated: () => void }): void;
|
||||
static getRemoteConfigsContentAsString(): string;
|
||||
static getABTestingId(): string;
|
||||
static getABTestingVariantId(): string;
|
||||
static addOnBeforeUnloadListener(listener: { onBeforeUnload: () => void }): void;
|
||||
static removeOnBeforeUnloadListener(listener: { onBeforeUnload: () => void }): void;
|
||||
}
|
||||
}
|
||||
|
||||
/** The default export — see the file header: this is the command-queue dispatcher, not the
|
||||
* `GameAnalytics` class. Call as `GameAnalyticsCommand('methodName', ...args)`. */
|
||||
const GameAnalyticsCommand: (command: string, ...args: unknown[]) => void;
|
||||
export default GameAnalyticsCommand;
|
||||
}
|
||||
+32
-3
@@ -1,14 +1,43 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const engineDir = path.resolve(rootDir, '../engine')
|
||||
const versionPath = path.resolve(rootDir, 'version.json')
|
||||
|
||||
// Read once per config evaluation — a dev-server restart (see restartOnVersionJsonChange below)
|
||||
// re-imports this whole file, so a version bump is picked up by simply restarting, without any
|
||||
// extra caching/invalidation logic here.
|
||||
const version = JSON.parse(readFileSync(versionPath, 'utf-8')) as string;
|
||||
|
||||
// version.json isn't part of the app's module graph (nothing imports it as JS), so editing it
|
||||
// doesn't trigger HMR or Vite's own config-file-triggered restart — only vite.config.ts itself is
|
||||
// watched for that. This plugin explicitly watches version.json too and forces a full restart on
|
||||
// change, so __APP_VERSION__ (set from `version` below) picks up a version bump without having to
|
||||
// stop/start `npm run dev` by hand.
|
||||
function restartOnVersionJsonChange(): Plugin {
|
||||
return {
|
||||
name: 'restart-on-version-json-change',
|
||||
configureServer(server) {
|
||||
server.watcher.add(versionPath)
|
||||
server.watcher.on('change', (file) => {
|
||||
if (path.normalize(file) === path.normalize(versionPath)) {
|
||||
server.config.logger.info('version.json changed, restarting to pick up __APP_VERSION__...', { clear: false })
|
||||
server.restart()
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [restartOnVersionJsonChange()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
resolve: {
|
||||
// Follow the node_modules symlink to the real sibling directory, so Vite's dev server
|
||||
// watches and transforms the actual engine source files (not a frozen pre-bundled copy).
|
||||
|
||||
Reference in New Issue
Block a user