Compare commits

5 Commits
65 changed files with 2305 additions and 947 deletions
+5
View File
@@ -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=
+3
View File
@@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.env
version.json
+1 -7
View File
@@ -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" 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> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+104 -99
View File
@@ -9,16 +9,12 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"break_eternity.js": "^2.1.3", "break_eternity.js": "^2.1.3",
"gameanalytics": "^5.0.0",
"mobx": "^7.0.3", "mobx": "^7.0.3",
"mobx-react-lite": "^5.0.3", "pixi.js": "^8.20.1"
"react": "^19.2.8",
"react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@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", "oxlint": "^1.79.0",
"tsx": "^4.23.13", "tsx": "^4.23.13",
"typescript": "~6.0.2", "typescript": "~6.0.2",
@@ -812,6 +808,12 @@
"node": "^20.19.0 || >=22.12.0" "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": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
@@ -1074,6 +1076,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -1084,54 +1092,19 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/react": { "node_modules/@webgpu/types": {
"version": "19.2.18", "version": "0.1.72",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
"dev": true, "license": "BSD-3-Clause"
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
}, },
"node_modules/@types/react-dom": { "node_modules/@xmldom/xmldom": {
"version": "19.2.7", "version": "0.8.15",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"dev": true,
"license": "MIT", "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": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": ">=10.0.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_modules/break_eternity.js": { "node_modules/break_eternity.js": {
@@ -1140,13 +1113,6 @@
"integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==", "integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==",
"license": "MIT" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1157,6 +1123,12 @@
"node": ">=8" "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": { "node_modules/esbuild": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -1199,6 +1171,12 @@
"@esbuild/win32-x64": "0.28.2" "@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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1232,6 +1210,33 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "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": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -1503,20 +1508,6 @@
"url": "https://opencollective.com/mobx" "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": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1605,6 +1602,32 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.28", "version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
@@ -1634,27 +1657,6 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/rolldown": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
@@ -1689,12 +1691,6 @@
"@rolldown/binding-win32-x64-msvc": "1.2.7" "@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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1705,6 +1701,15 @@
"node": ">=0.10.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+4 -7
View File
@@ -9,20 +9,17 @@
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview", "preview": "vite preview",
"sim": "tsx scripts/simulate.ts", "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": { "dependencies": {
"break_eternity.js": "^2.1.3", "break_eternity.js": "^2.1.3",
"gameanalytics": "^5.0.0",
"mobx": "^7.0.3", "mobx": "^7.0.3",
"mobx-react-lite": "^5.0.3", "pixi.js": "^8.20.1"
"react": "^19.2.8",
"react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@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", "oxlint": "^1.79.0",
"tsx": "^4.23.13", "tsx": "^4.23.13",
"typescript": "~6.0.2", "typescript": "~6.0.2",
Binary file not shown.
Binary file not shown.
+11 -4
View File
@@ -16,6 +16,8 @@
* Run with: npx tsx scripts/play.ts [tickSeconds] * Run with: npx tsx scripts/play.ts [tickSeconds]
*/ */
import readline from 'node:readline'; 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 { bushesForDay, treesForDay } from '../src/game/constants';
import { formatClock } from '../src/game/daynight'; import { formatClock } from '../src/game/daynight';
import { Rabbit } from '../src/game/entities/Rabbit'; 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 { GameStore } from '../src/game/GameStore';
import { costForNextLevel, UPGRADE_LIST, type UpgradeId } from '../src/game/upgrades'; import { costForNextLevel, UPGRADE_LIST, type UpgradeId } from '../src/game/upgrades';
const MAP_WIDTH = 1200; const MAP_WIDTH = 400;
const MAP_HEIGHT = 800; const MAP_HEIGHT = 260;
/** How many simulated seconds pass per tick — fixed, independent of how fast ticks actually run. */ /** How many simulated seconds pass per tick — fixed, independent of how fast ticks actually run. */
const TICK_SECONDS = Number(process.argv[2] ?? 0.1); const TICK_SECONDS = Number(process.argv[2] ?? 0.1);
/** Ticks per event-loop turn between yields — batching amortizes the yield's overhead while /** 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. */ * still keeping Ctrl+C/output responsive within about one batch's worth of latency. */
const TICKS_PER_BATCH = 1000; const TICKS_PER_BATCH = 1000;
/** How far past the nearest rabbit to aim while fleeing it. */ /** 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); store.initialize(MAP_WIDTH, MAP_HEIGHT);
const startedAt = Date.now(); const startedAt = Date.now();
-57
View File
@@ -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
View File
@@ -52,6 +52,7 @@
} }
.main-menu { .main-menu {
position: relative;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -61,6 +62,15 @@
background: var(--panel-bg); 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 { .main-menu__title {
font-size: 32px; font-size: 32px;
margin: 0; margin: 0;
@@ -75,6 +85,12 @@
width: 260px; width: 260px;
} }
/* Temporary — see MainMenu.ts/DebugCharacterPreview.ts. */
.main-menu__debug-preview {
width: 128px;
height: 128px;
}
.menu-btn { .menu-btn {
padding: 16px; padding: 16px;
background: rgba(0, 0, 0, 0.25); background: rgba(0, 0, 0, 0.25);
@@ -95,6 +111,28 @@
cursor: not-allowed; 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 { .main-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -199,132 +237,9 @@
user-select: none; user-select: none;
} }
.health-bar { /* Wraps the blood-moon banner and death screen, both absolutely positioned over the game canvas
position: absolute; * (see GameOverlays.ts) — pointer-events is re-enabled on .game-over-overlay below for its button. */
width: 28px; .game-overlays {
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 {
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none; 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 { .game-over-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -366,6 +288,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 24px; gap: 24px;
pointer-events: auto;
} }
.game-over-overlay__message { .game-over-overlay__message {
@@ -385,27 +308,3 @@
flex-direction: column; flex-direction: column;
gap: 4px; 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
View File
@@ -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;
+9
View File
@@ -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);
}
}
+74
View File
@@ -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);
}
}
+10
View File
@@ -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 { }
}
+98
View File
@@ -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
View File
@@ -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;
}
}
+71
View File
@@ -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 };
}
+56
View File
@@ -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();
}
}
+75
View File
@@ -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;
}
+84
View File
@@ -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;
}
+72
View File
@@ -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 };
}
+72
View File
@@ -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 };
}
+35
View File
@@ -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
View File
@@ -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

-33
View File
@@ -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;
-225
View File
@@ -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);
-10
View File
@@ -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;
-29
View File
@@ -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;
-27
View File
@@ -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;
-35
View File
@@ -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);
-33
View File
@@ -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;
-23
View File
@@ -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;
-50
View File
@@ -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">&gt;</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);
-47
View File
@@ -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
View File
@@ -17,9 +17,11 @@ import {
RABBIT_STRIKE_RANGE, RABBIT_STRIKE_RANGE,
rabbitsForDay, rabbitsForDay,
} from './rabbits'; } from './rabbits';
import { Random, randomSeed } from './random';
import { pickSpawnPositions } from './spawning'; import { pickSpawnPositions } from './spawning';
import type { Inventory, ResourceType, Vec2 } from './types'; import type { Inventory, ResourceType, Vec2 } from './types';
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades'; import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
import type { Analytics } from '../analytics';
/** How often (seconds of played time) the game autosaves while running. */ /** How often (seconds of played time) the game autosaves while running. */
const AUTOSAVE_INTERVAL = 2; const AUTOSAVE_INTERVAL = 2;
@@ -68,6 +70,9 @@ export class GameStore {
/** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */ /** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */
bloodMoonBannerTimer = 0; bloodMoonBannerTimer = 0;
upgradeLevels: Record<UpgradeId, number> = { ...DEFAULT_UPGRADE_LEVELS }; 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, // 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()). // 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 /** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes
* down when coins are spent on upgrades. */ * down when coins are spent on upgrades. */
totalCoinsEarned = 0; 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 coinProgress = 0;
private saveProgress = 0; private saveProgress = 0;
private lastTimestamp: number | null = null; private lastTimestamp: number | null = null;
@@ -87,8 +100,18 @@ export class GameStore {
private lastAnnouncedBloodMoonDay = -1; private lastAnnouncedBloodMoonDay = -1;
/** Map dimensions from the last initialize(), reused to grow each new day's nodes. */ /** Map dimensions from the last initialize(), reused to grow each new day's nodes. */
private mapSize: { width: number; height: number } | null = null; 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, // autoBind so methods stay correctly bound when passed as bare references (event handlers,
// e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`). // e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`).
// Deliberately no side effects (e.g. global event listener registration) here beyond // 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 // 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 // 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 // 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 }); makeAutoObservable(this, {}, { autoBind: true });
} }
@@ -238,9 +265,16 @@ export class GameStore {
this.inventory.coins -= cost; this.inventory.coins -= cost;
this.upgradeLevels[id] = level + 1; this.upgradeLevels[id] = level + 1;
this.analytics.buyUpgrade(id, level + 1);
return true; 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/ /** (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 * drops/progress) if one exists; otherwise places the character in the center and scatters day
* 1's trees and bushes. */ * 1's trees and bushes. */
@@ -259,6 +293,14 @@ export class GameStore {
this.day = save?.day ?? 1; this.day = save?.day ?? 1;
this.hour = save?.hour ?? 12; 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 // 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 // "Новая игра" 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 // 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.bushesChoppedCount = save?.bushesChoppedCount ?? 0;
this.rabbitsCaughtCount = save?.rabbitsCaughtCount ?? 0; this.rabbitsCaughtCount = save?.rabbitsCaughtCount ?? 0;
this.totalCoinsEarned = save?.totalCoinsEarned ?? 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 // Rabbits are restored/spawned independently of the world-state branch above so a save from
// before rabbits existed still loads fine — it just gets a freshly spawned batch of them. // before rabbits existed still loads fine — it just gets a freshly spawned batch of them.
@@ -333,14 +377,17 @@ export class GameStore {
bushesChoppedCount: this.bushesChoppedCount, bushesChoppedCount: this.bushesChoppedCount,
rabbitsCaughtCount: this.rabbitsCaughtCount, rabbitsCaughtCount: this.rabbitsCaughtCount,
totalCoinsEarned: this.totalCoinsEarned, 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, /** Scatters day 1's trees and bushes, keeping every node clear of the character's starting spot,
* the map edge, and each other regardless of kind. */ * the map edge, and each other regardless of kind. */
private spawnNodes(width: number, height: number): ResourceNode[] { private spawnNodes(width: number, height: number): ResourceNode[] {
const treePositions = pickSpawnPositions(treesForDay(this.day), width, height, []); const treePositions = pickSpawnPositions(treesForDay(this.day), width, height, [], this.random);
const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions); const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions, this.random);
return [ return [
...treePositions.map((pos) => ResourceNode.spawnNew('tree', pos)), ...treePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
...bushPositions.map((pos) => ResourceNode.spawnNew('bush', 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 treeDeficit = Math.max(0, treesForDay(this.day) - this.nodes.filter((n) => n.kind === 'tree').length);
const bushDeficit = Math.max(0, bushesForDay(this.day) - this.nodes.filter((n) => n.kind === 'bush').length); const bushDeficit = Math.max(0, bushesForDay(this.day) - this.nodes.filter((n) => n.kind === 'bush').length);
const newTreePositions = pickSpawnPositions(treeDeficit, width, height, existing); const newTreePositions = pickSpawnPositions(treeDeficit, width, height, existing, this.random);
const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions]); const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions], this.random);
const newNodes = [ const newNodes = [
...newTreePositions.map((pos) => ResourceNode.spawnNew('tree', pos)), ...newTreePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
...newBushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)), ...newBushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)),
@@ -370,8 +417,8 @@ export class GameStore {
} }
private spawnRabbits(width: number, height: number, count: number): Rabbit[] { private spawnRabbits(width: number, height: number, count: number): Rabbit[] {
const positions = pickSpawnPositions(count, width, height, []); const positions = pickSpawnPositions(count, width, height, [], this.random);
return positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp)); 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 /** 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; return;
const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y })); const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y }));
const positions = pickSpawnPositions(deficit, width, height, existing); const positions = pickSpawnPositions(deficit, width, height, existing, this.random);
this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp))]; this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp, this.random))];
} }
holdNode(id: string) { holdNode(id: string) {
@@ -461,6 +508,7 @@ export class GameStore {
if (this.isDead) if (this.isDead)
return; return;
this.playTimeSeconds += deltaTime;
this.updateClock(deltaTime); this.updateClock(deltaTime);
this.updateBloodMoon(deltaTime); this.updateBloodMoon(deltaTime);
this.updateMovementAndChopping(deltaTime); this.updateMovementAndChopping(deltaTime);
@@ -510,6 +558,7 @@ export class GameStore {
this.characterHitFlashTimer = HIT_FLASH_DURATION; this.characterHitFlashTimer = HIT_FLASH_DURATION;
if (this.character.hp <= 0) { if (this.character.hp <= 0) {
this.isDead = true; this.isDead = true;
this.analytics.death('killed_by_rabbit');
clearSave(); clearSave();
} }
} }
@@ -518,6 +567,7 @@ export class GameStore {
* (upgradable) day speed otherwise — rolling over into a new day, which grows fresh plants and * (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. */ * rabbits up to the new day's caps, whenever it passes midnight. */
private updateClock(deltaTime: number) { private updateClock(deltaTime: number) {
const wasNight = this.isNight;
const speed = this.isNight ? NIGHT_SPEED : this.daySpeedHoursPerSecond; const speed = this.isNight ? NIGHT_SPEED : this.daySpeedHoursPerSecond;
this.hour += speed * deltaTime; this.hour += speed * deltaTime;
while (this.hour >= 24) { while (this.hour >= 24) {
@@ -526,6 +576,16 @@ export class GameStore {
this.growNewNodesForDay(); this.growNewNodesForDay();
this.growRabbitsForDay(); 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 /** Each rabbit independently cycles: pause (deciding its next action) → jump or strike
@@ -543,6 +603,7 @@ export class GameStore {
mapWidth: this.mapSize.width, mapWidth: this.mapSize.width,
mapHeight: this.mapSize.height, mapHeight: this.mapSize.height,
onStrike: () => this.landRabbitStrike(), onStrike: () => this.landRabbitStrike(),
random: this.random,
}; };
for (const rabbit of this.rabbits) { for (const rabbit of this.rabbits) {
rabbit.regenerateHealth(deltaTime, RABBIT_HEALTH_REGEN_INTERVAL, maxHp); rabbit.regenerateHealth(deltaTime, RABBIT_HEALTH_REGEN_INTERVAL, maxHp);
@@ -661,7 +722,7 @@ export class GameStore {
} }
private ejectDrop(origin: Vec2, resource: ResourceType) { 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 /** Advances every resource drop currently in a ballistic flight (falling from a node, or flying
+7 -7
View File
@@ -13,31 +13,31 @@ export const TWIGS_PER_TREE = 2;
export const TWIGS_PER_BUSH = 5; export const TWIGS_PER_BUSH = 5;
/** How close the character must get to a node's center to start chopping it. */ /** 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. */ /** 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. */ /** Seconds for a resource's ballistic flight from the node to its landing spot. */
export const DROP_FALL_DURATION = 0.5; export const DROP_FALL_DURATION = 0.5;
/** Peak height (px) of a resource's arc while falling from the node. */ /** 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. */ /** Seconds for a landed resource's flight into the character's inventory. */
export const DROP_COLLECT_DURATION = 0.25; export const DROP_COLLECT_DURATION = 0.25;
/** Peak height (px) of a resource's little hop while flying into the inventory. */ /** 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. */ /** 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. */ /** 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. */ /** 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) — /** 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. */ * a brief flash at the moment of impact, not for however long the attack itself takes. */
+4 -8
View File
@@ -61,9 +61,9 @@ const OVERLAY_KEYFRAMES: OverlayKeyframe[] = [
{ hour: 24, r: 10, g: 10, b: 35, a: 0.55 }, { 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 — /** The ambient darkness/tint overlay for the given time of day, as {r,g,b,a} (0-255 channels,
* interpolated piecewise-linearly between OVERLAY_KEYFRAMES. */ * 0-1 alpha) — interpolated piecewise-linearly between OVERLAY_KEYFRAMES. */
export function overlayColorForHour(hour: number): string { export function overlayColorComponentsForHour(hour: number): { r: number; g: number; b: number; a: number } {
const h = ((hour % 24) + 24) % 24; const h = ((hour % 24) + 24) % 24;
let prev = OVERLAY_KEYFRAMES[0]; let prev = OVERLAY_KEYFRAMES[0];
let next = OVERLAY_KEYFRAMES[OVERLAY_KEYFRAMES.length - 1]; 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 span = next.hour - prev.hour;
const t = span === 0 ? 0 : (h - prev.hour) / span; const t = span === 0 ? 0 : (h - prev.hour) / span;
const lerp = (a: number, b: number) => a + (b - a) * t; const lerp = (a: number, b: number) => a + (b - a) * t;
const r = lerp(prev.r, next.r); 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) };
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)})`;
} }
+8 -4
View File
@@ -1,6 +1,7 @@
import { makeAutoObservable } from 'mobx'; import { makeAutoObservable } from 'mobx';
import { ENTITY_EDGE_MARGIN } from '../constants'; import { ENTITY_EDGE_MARGIN } from '../constants';
import { randomId } from '../id'; import { randomId } from '../id';
import type { Random } from '../random';
import { import {
RABBIT_ACTION_DURATION, RABBIT_ACTION_DURATION,
RABBIT_JUMP_ARC_HEIGHT, 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 /** Called the instant a strike begins — damage lands at the *start* of the action, not once it
* finishes playing out. */ * finishes playing out. */
onStrike: () => void; 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 /** 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 /** Builds a fresh rabbit at `pos`, full hp, staggered mid-pause so freshly spawned rabbits don't
* all start their first action in lockstep. */ * all start their first action in lockstep. */
static spawnNew(pos: Vec2, maxHp: number): Rabbit { static spawnNew(pos: Vec2, maxHp: number, random: Random): Rabbit {
return new Rabbit(pos.x, pos.y, maxHp, 'idle', Math.random() * RABBIT_PAUSE_DURATION); 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 /** 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. // overshooting past them.
const towardCharacter = Math.atan2(ctx.characterPos.y - this.y, ctx.characterPos.x - this.x); const towardCharacter = Math.atan2(ctx.characterPos.y - this.y, ctx.characterPos.x - this.x);
const spread = RABBIT_NIGHT_JUMP_SPREAD_DEGREES * (Math.PI / 180); 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); jumpDistance = Math.min(RABBIT_JUMP_DISTANCE, distanceToCharacter);
} else { } else {
angle = this.pickDayJumpAngle(ctx); angle = this.pickDayJumpAngle(ctx);
@@ -241,7 +245,7 @@ export class Rabbit {
return awayAngle; return awayAngle;
for (let attempt = 0; attempt < 8; attempt++) { 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 targetX = this.x + Math.cos(angle) * RABBIT_JUMP_DISTANCE;
const targetY = this.y + Math.sin(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); const distanceToCharacter = Math.hypot(ctx.characterPos.x - targetX, ctx.characterPos.y - targetY);
+4 -3
View File
@@ -7,6 +7,7 @@ import {
DROP_FALL_DURATION, DROP_FALL_DURATION,
} from '../constants'; } from '../constants';
import { randomId } from '../id'; import { randomId } from '../id';
import type { Random } from '../random';
import type { DropPhase, Flight, ResourceDropData, ResourceType, Vec2 } from '../types'; 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 /** 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 /** 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). */ * it; it only becomes pickable once it lands (see updateFlight). */
static eject(origin: Vec2, resource: ResourceType): ResourceDrop { static eject(origin: Vec2, resource: ResourceType, random: Random): ResourceDrop {
const angle = Math.random() * Math.PI * 2; const angle = random.next() * Math.PI * 2;
const landDistance = Math.random() * DROP_EJECT_RADIUS; const landDistance = random.next() * DROP_EJECT_RADIUS;
const toX = origin.x + Math.cos(angle) * landDistance; const toX = origin.x + Math.cos(angle) * landDistance;
const toY = origin.y + Math.sin(angle) * landDistance; const toY = origin.y + Math.sin(angle) * landDistance;
return new ResourceDrop(resource, origin.x, origin.y, 0, 'falling', { return new ResourceDrop(resource, origin.x, origin.y, 0, 'falling', {
+10
View File
@@ -29,6 +29,16 @@ export interface SaveData {
/** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes /** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes
* down when coins are spent on upgrades. */ * down when coins are spent on upgrades. */
totalCoinsEarned?: number; 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 { function isValidSave(value: unknown): value is SaveData {
+5 -5
View File
@@ -10,20 +10,20 @@ export const RABBIT_ACTION_DURATION = 0.5;
export const RABBIT_PAUSE_DURATION = 0.5; export const RABBIT_PAUSE_DURATION = 0.5;
/** Max distance covered by a single jump. */ /** Max distance covered by a single jump. */
export const RABBIT_JUMP_DISTANCE = 80; export const RABBIT_JUMP_DISTANCE = 27;
/** Peak height (px) of a jump's parabolic arc — same idea as a resource drop's arc when it flies /** Peak height (px) of a jump's parabolic arc — same idea as a resource drop's arc when it flies
* off a chopped tree. */ * 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. */ /** 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. */ /** 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. */ /** 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). */ /** A rabbit's max HP (fixed — unlike the character's, there's no upgrade for it). */
export const RABBIT_MAX_HP = 3; export const RABBIT_MAX_HP = 3;
+39
View File
@@ -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);
}
+4 -3
View File
@@ -1,10 +1,11 @@
import { ENTITY_EDGE_MARGIN, ENTITY_MIN_DISTANCE_BETWEEN, ENTITY_MIN_DISTANCE_FROM_CENTER } from './constants'; 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'; import type { Vec2 } from './types';
/** Scatters up to `count` new positions across a `width` x `height` map, keeping clear of the map /** 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 * 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. */ * — 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 centerX = width / 2;
const centerY = height / 2; const centerY = height / 2;
const positions: Vec2[] = []; const positions: Vec2[] = [];
@@ -12,8 +13,8 @@ export function pickSpawnPositions(count: number, width: number, height: number,
while (positions.length < count && attempts < 1000) { while (positions.length < count && attempts < 1000) {
attempts++; attempts++;
const x = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2); const x = ENTITY_EDGE_MARGIN + random.next() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2);
const y = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, height - 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) if (Math.hypot(x - centerX, y - centerY) < ENTITY_MIN_DISTANCE_FROM_CENTER)
continue; continue;
+2 -2
View File
@@ -27,7 +27,7 @@ export const UPGRADE_LIST: UpgradeDef[] = [
name: 'Скорость движения', name: 'Скорость движения',
baseCost: 5, baseCost: 5,
costGrowth: 1.35, costGrowth: 1.35,
effectAtLevel: (level) => 160 + (level - 1) * 20, effectAtLevel: (level) => 50 + (level - 1) * 6,
formatEffect: (v) => `${Math.round(v)} px/с`, formatEffect: (v) => `${Math.round(v)} px/с`,
}, },
{ {
@@ -67,7 +67,7 @@ export const UPGRADE_LIST: UpgradeDef[] = [
name: 'Радиус подбора', name: 'Радиус подбора',
baseCost: 5, baseCost: 5,
costGrowth: 1.3, costGrowth: 1.3,
effectAtLevel: (level) => 46 + (level - 1) * 6, effectAtLevel: (level) => 15 + (level - 1) * 2,
formatEffect: (v) => `${Math.round(v)} px`, formatEffect: (v) => `${Math.round(v)} px`,
}, },
{ {
+19
View File
@@ -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 { :root {
--panel-bg: #3a2a1e; --panel-bg: #3a2a1e;
--panel-border: #1b1611; --panel-border: #1b1611;
+8
View File
@@ -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')!);
-14
View File
@@ -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>,
)
+35
View File
@@ -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 };
}
+178
View File
@@ -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();
};
}
+60
View File
@@ -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;
}
}
+162
View File
@@ -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
}
}
+51
View File
@@ -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,
};
}
+55
View File
@@ -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);
}
}
}
+72
View File
@@ -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 };
}
}
+85
View File
@@ -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;
}
}
+119
View File
@@ -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 };
}
}
+42
View File
@@ -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));
}
+57
View File
@@ -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;
}
+15
View File
@@ -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 });
}
+81
View File
@@ -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;
}
+12
View File
@@ -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
View File
@@ -7,6 +7,15 @@
"types": ["vite/client"], "types": ["vite/client"],
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": 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 */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
@@ -14,7 +23,6 @@
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx",
/* Linting */ /* Linting */
"noUnusedLocals": true, "noUnusedLocals": true,
+85
View File
@@ -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
View File
@@ -1,14 +1,43 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'node:path' import path from 'node:path'
import react from '@vitejs/plugin-react' import { defineConfig, type Plugin } from 'vite'
import { defineConfig } from 'vite'
const rootDir = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.dirname(fileURLToPath(import.meta.url))
const engineDir = path.resolve(rootDir, '../engine') 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [restartOnVersionJsonChange()],
define: {
__APP_VERSION__: JSON.stringify(version),
},
resolve: { resolve: {
// Follow the node_modules symlink to the real sibling directory, so Vite's dev server // 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). // watches and transforms the actual engine source files (not a frozen pre-bundled copy).