миграция на pixi
This commit is contained in:
+1
-7
@@ -8,15 +8,9 @@
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>forest</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+97
-99
@@ -11,15 +11,10 @@
|
||||
"break_eternity.js": "^2.1.3",
|
||||
"gameanalytics": "^5.0.0",
|
||||
"mobx": "^7.0.3",
|
||||
"mobx-react-lite": "^5.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"pixi.js": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "~6.0.2",
|
||||
@@ -813,6 +808,12 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pixi/colord": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
|
||||
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
|
||||
@@ -1075,6 +1076,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/earcut": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
|
||||
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
@@ -1085,54 +1092,19 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.72",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
|
||||
"integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==",
|
||||
"dev": true,
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
|
||||
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"oxc-transform-react": "^0.145.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rolldown/plugin-babel": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
},
|
||||
"oxc-transform-react": {
|
||||
"optional": true
|
||||
}
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/break_eternity.js": {
|
||||
@@ -1141,13 +1113,6 @@
|
||||
"integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1158,6 +1123,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -1200,6 +1171,12 @@
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1239,6 +1216,27 @@
|
||||
"integrity": "sha512-Hdns5ejXvAXGo8EVlavjNwaKnH+tBHJY83Vf2Cs04FuNPkKZ7HjWC9pB/MO1NnOVdzJFq37dbDGjyfxDC3iNzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gifuct-js": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
|
||||
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-binary-schema-parser": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ismobilejs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
|
||||
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-binary-schema-parser": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
|
||||
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
@@ -1510,20 +1508,6 @@
|
||||
"url": "https://opencollective.com/mobx"
|
||||
}
|
||||
},
|
||||
"node_modules/mobx-react-lite": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-5.0.3.tgz",
|
||||
"integrity": "sha512-8qT/d/8Cg8ljt+heKhoFHNoqgedyeSGzdTixT0lgbFMMFlWfHUcXHZ8UD63eQi3ELjkxaH1VU07lXWhRwM0cVA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mobx"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mobx": "^7.0.0",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
@@ -1592,6 +1576,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
|
||||
"integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1612,6 +1602,32 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pixi.js": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.1.tgz",
|
||||
"integrity": "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"examples",
|
||||
"playground"
|
||||
],
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6",
|
||||
"@types/earcut": "^3.0.0",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"earcut": "^3.0.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"ismobilejs": "^1.1.1",
|
||||
"parse-svg-path": "^0.2.0",
|
||||
"tiny-lru": "^11.4.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/pixijs"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
@@ -1641,27 +1657,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
|
||||
@@ -1696,12 +1691,6 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1712,6 +1701,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-lru": {
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
|
||||
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
+1
-6
@@ -16,15 +16,10 @@
|
||||
"break_eternity.js": "^2.1.3",
|
||||
"gameanalytics": "^5.0.0",
|
||||
"mobx": "^7.0.3",
|
||||
"mobx-react-lite": "^5.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"pixi.js": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+11
-150
@@ -209,132 +209,9 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.health-bar {
|
||||
position: absolute;
|
||||
width: 28px;
|
||||
height: 5px;
|
||||
transform: translate(-50%, -100%);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid #1b1611;
|
||||
z-index: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.health-bar__fill {
|
||||
height: 100%;
|
||||
background: #4caf50;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.entity {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.entity.log-shadow {
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
margin: 8px 0px 0px 0px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.twig-shadow {
|
||||
width: 18px;
|
||||
height: 9px;
|
||||
margin: 7px 0px 0px 0px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.tree-shadow {
|
||||
width: 64px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
margin: 26px 0px 0px -11px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.bush-shadow {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
margin: 16px 0px 0px -6px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.character-shadow {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin: 32px 0px 0px 0px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.rabbit-shadow {
|
||||
width: 18px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
margin: 32px 10px 0px 0px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.meat-shadow {
|
||||
width: 18px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
margin: 7px 0px 0px 0px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.tree {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
margin: -12px;
|
||||
}
|
||||
|
||||
.entity.bush {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
margin: -8px;
|
||||
}
|
||||
|
||||
.entity.rabbit {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
margin: -6px;
|
||||
}
|
||||
|
||||
.entity.chopping svg {
|
||||
animation: chop-shake 0.12s infinite;
|
||||
}
|
||||
|
||||
.entity.character {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.entity.character.character--walking svg {
|
||||
animation: walk-bob 0.3s infinite steps(2);
|
||||
}
|
||||
|
||||
/* Same specificity as the walk-bob rule above (and placed after it), so the hit-shake still
|
||||
* shows on the brief flash even if the character happens to be walking at that exact moment. */
|
||||
.entity.character.chopping svg {
|
||||
animation: chop-shake 0.12s infinite;
|
||||
}
|
||||
|
||||
.day-night-overlay {
|
||||
/* Wraps the blood-moon banner and death screen, both absolutely positioned over the game canvas
|
||||
* (see GameOverlays.ts) — pointer-events is re-enabled on .game-over-overlay below for its button. */
|
||||
.game-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
@@ -366,6 +243,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Author `display: flex` below otherwise wins the cascade over the UA stylesheet's
|
||||
* `[hidden] { display: none }` (equal specificity, author beats user-agent) — without this, toggling
|
||||
* the `hidden` IDL property (see GameOverlays.ts) wouldn't actually hide the element. */
|
||||
.game-over-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-over-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -376,6 +260,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-over-overlay__message {
|
||||
@@ -395,27 +280,3 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@keyframes chop-shake {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translate(-2px, 1px) rotate(-6deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(1px, -2px) rotate(5deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-1px, 1px) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes walk-bob {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
-79
@@ -1,79 +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, hasSave } from './game/persistence';
|
||||
import './App.css';
|
||||
import { Analytics, makeAnalyticsGameInfo } from './analytics';
|
||||
import { GameAnalyticsTransport } from './analytics/GameAnalyticsTransport';
|
||||
import { NullAnalyticsTransport } from './analytics/NullAnalyticsTransport';
|
||||
import type { AnalyticsTransport } from './analytics/AnalyticsTransport';
|
||||
|
||||
type Screen = 'menu' | 'game';
|
||||
|
||||
let store: GameStore;
|
||||
|
||||
// Module-level singleton, constructed once when this module first loads — not inside the
|
||||
// useState initializer below, since React StrictMode double-invokes that in dev, and
|
||||
// GameAnalyticsProvider's constructor registers global listeners and opens a session as a
|
||||
// side effect; double-constructing it would leak a listener set and open two sessions.
|
||||
const GA_GAME_KEY = import.meta.env.VITE_GAMEANALYTICS_GAME_KEY;
|
||||
const GA_GAME_SECRET = import.meta.env.VITE_GAMEANALYTICS_GAME_SECRET;
|
||||
let analyticsTransport: AnalyticsTransport;
|
||||
if (!GA_GAME_KEY || !GA_GAME_SECRET) {
|
||||
console.warn('VITE_GAMEANALYTICS_GAME_KEY/VITE_GAMEANALYTICS_GAME_SECRET not set — analytics disabled (see .env.example).');
|
||||
analyticsTransport = new NullAnalyticsTransport();
|
||||
}
|
||||
else {
|
||||
analyticsTransport = new GameAnalyticsTransport(GA_GAME_KEY, GA_GAME_SECRET)
|
||||
}
|
||||
const analytics = new Analytics(analyticsTransport, () => makeAnalyticsGameInfo(store));
|
||||
analytics.start();
|
||||
|
||||
store = new GameStore(analytics);
|
||||
|
||||
function App() {
|
||||
// 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() {
|
||||
// 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())
|
||||
analytics.death('restarted');
|
||||
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 analytics={analytics} onNewGame={handleNewGame} onContinue={() => setScreen('game')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Topbar store={store} onExitToMenu={handleExitToMenu} />
|
||||
<div className="main-row">
|
||||
<Sidebar store={store} />
|
||||
<GameMap store={store} onDeath={handleDeath} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,95 @@
|
||||
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';
|
||||
|
||||
/** 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.root.replaceChildren(menu);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private handleNewGame(): 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();
|
||||
this.showGame();
|
||||
}
|
||||
|
||||
private handleContinue(): void {
|
||||
this.showGame();
|
||||
}
|
||||
|
||||
private handleExitToMenu(): void {
|
||||
this.store.save();
|
||||
this.showMenu();
|
||||
}
|
||||
|
||||
// Called after a death overlay is acknowledged: the store already wiped the save itself the
|
||||
// moment the character was caught, so this just returns to the menu without saving over it.
|
||||
private handleDeath(): void {
|
||||
this.showMenu();
|
||||
}
|
||||
|
||||
private teardownGameScreen(): void {
|
||||
this.gameScreen?.destroy();
|
||||
this.gameScreen = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { autorun } from 'mobx';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import type { Mounted } from './Topbar';
|
||||
|
||||
/** Russian plural agreement for "день" (day) after a count. */
|
||||
function dayWord(n: number): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11)
|
||||
return 'день';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
|
||||
return 'дня';
|
||||
return 'дней';
|
||||
}
|
||||
|
||||
/** Builds the "КРОВАВАЯ ЛУНА!" banner and the death screen, both absolutely positioned over the
|
||||
* game map (siblings of the Pixi canvas — see App.ts) — the DOM-side replacement for the
|
||||
* corresponding pieces of GameMap.tsx. Both stay hidden/empty until the store says otherwise, kept
|
||||
* in sync via a single mobx autorun. */
|
||||
export function createGameOverlays(store: GameStore, onDeath: () => void): Mounted {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'game-overlays';
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'blood-moon-banner';
|
||||
banner.textContent = 'КРОВАВАЯ ЛУНА!';
|
||||
banner.hidden = true;
|
||||
|
||||
const gameOver = document.createElement('div');
|
||||
gameOver.className = 'game-over-overlay';
|
||||
gameOver.hidden = true;
|
||||
|
||||
const message = document.createElement('div');
|
||||
message.className = 'game-over-overlay__message';
|
||||
|
||||
const stats = document.createElement('div');
|
||||
stats.className = 'game-over-overlay__stats';
|
||||
const distance = document.createElement('div');
|
||||
const chopped = document.createElement('div');
|
||||
const caught = document.createElement('div');
|
||||
const coins = document.createElement('div');
|
||||
stats.append(distance, chopped, caught, coins);
|
||||
|
||||
const menuBtn = document.createElement('button');
|
||||
menuBtn.type = 'button';
|
||||
menuBtn.className = 'menu-btn';
|
||||
// The whole map underneath (including this overlay) has a pointerdown handler that captures the
|
||||
// pointer and drives character movement — without stopping it here, that handler fires first on
|
||||
// bubble-up and swallows the tap before it becomes a click (see GameCanvas).
|
||||
menuBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
menuBtn.addEventListener('click', onDeath);
|
||||
menuBtn.textContent = 'В меню';
|
||||
|
||||
gameOver.append(message, stats, menuBtn);
|
||||
root.append(banner, gameOver);
|
||||
|
||||
const dispose = autorun(() => {
|
||||
banner.hidden = !store.bloodMoonBannerVisible;
|
||||
|
||||
gameOver.hidden = !store.isDead;
|
||||
if (!store.isDead)
|
||||
return;
|
||||
message.textContent = `Вы прожили ${store.day} ${dayWord(store.day)}, но вас поймали кролики`;
|
||||
distance.textContent = `Пройдено: ${Math.round(store.character.distanceTraveled)} px`;
|
||||
chopped.textContent = `Срублено: ${store.treesChoppedCount} деревьев, ${store.bushesChoppedCount} кустов`;
|
||||
caught.textContent = `Поймано кроликов: ${store.rabbitsCaughtCount}`;
|
||||
coins.textContent = `Заработано монет: ${store.totalCoinsEarned.toFixed(1)}`;
|
||||
});
|
||||
|
||||
return { el: root, dispose };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { GameCanvas } from '../pixi/GameCanvas';
|
||||
import { createGameOverlays } from './GameOverlays';
|
||||
import { createSidebar } from './Sidebar';
|
||||
import { createTopbar } from './Topbar';
|
||||
|
||||
/** Builds the "playing" screen (topbar, sidebar, game map) and wires up the Pixi canvas — the
|
||||
* DOM-side replacement for App.tsx's game-screen JSX plus GameMap.tsx's own structure. */
|
||||
export class GameScreen {
|
||||
readonly el: HTMLElement;
|
||||
|
||||
private readonly canvas = new GameCanvas();
|
||||
private readonly topbar: ReturnType<typeof createTopbar>;
|
||||
private readonly sidebar: ReturnType<typeof createSidebar>;
|
||||
private readonly overlays: ReturnType<typeof createGameOverlays>;
|
||||
private readonly mapEl: HTMLElement;
|
||||
private readonly store: GameStore;
|
||||
|
||||
constructor(store: GameStore, onExitToMenu: () => void, onDeath: () => void) {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'app';
|
||||
|
||||
this.topbar = createTopbar(store, onExitToMenu);
|
||||
|
||||
const mainRow = document.createElement('div');
|
||||
mainRow.className = 'main-row';
|
||||
|
||||
this.sidebar = createSidebar(store);
|
||||
|
||||
const mapEl = document.createElement('div');
|
||||
mapEl.className = 'game-map';
|
||||
this.overlays = createGameOverlays(store, onDeath);
|
||||
mapEl.appendChild(this.overlays.el);
|
||||
|
||||
mainRow.append(this.sidebar.el, mapEl);
|
||||
this.el.append(this.topbar.el, mainRow);
|
||||
|
||||
this.mapEl = mapEl;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
/** Starts the Pixi canvas. Must be called only after `el` is attached to the document — the
|
||||
* canvas is sized from the map div's clientWidth/Height, which is 0 on a detached element, so
|
||||
* calling this from the constructor (before App.ts inserts `el` into the page) would silently
|
||||
* measure a zero-size box and never create the canvas at all. */
|
||||
mount(): void {
|
||||
void this.canvas.mount(this.mapEl, this.store);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.canvas.destroy();
|
||||
this.topbar.dispose();
|
||||
this.sidebar.dispose();
|
||||
this.overlays.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Analytics } from '../analytics';
|
||||
import { hasSave } from '../game/persistence';
|
||||
|
||||
/** 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): 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();
|
||||
});
|
||||
|
||||
buttons.append(newGameBtn, continueBtn);
|
||||
|
||||
const version = document.createElement('div');
|
||||
version.className = 'main-menu__version';
|
||||
version.textContent = `v${__APP_VERSION__}`;
|
||||
|
||||
root.append(title, buttons, version);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { autorun } from 'mobx';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { COIN_SPRITE } from '../game/sprites';
|
||||
import { costForNextLevel, UPGRADE_LIST } from '../game/upgrades';
|
||||
import { pixelIcon } from './pixelIcon';
|
||||
import type { Mounted } from './Topbar';
|
||||
|
||||
/** Builds the sidebar's upgrade list, keeping each button's level/effects/cost/disabled state in
|
||||
* sync via a single mobx autorun — the DOM-side replacement for Sidebar.tsx. */
|
||||
export function createSidebar(store: GameStore): Mounted {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'sidebar';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'sidebar__title';
|
||||
title.textContent = 'Апгрейды';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'upgrade-list';
|
||||
|
||||
const rows = UPGRADE_LIST.map((def) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'upgrade-btn';
|
||||
btn.addEventListener('click', () => store.buyUpgrade(def.id));
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'upgrade-btn__header';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'upgrade-btn__name';
|
||||
name.textContent = def.name;
|
||||
const level = document.createElement('span');
|
||||
level.className = 'upgrade-btn__level';
|
||||
header.append(name, level);
|
||||
|
||||
const effects = document.createElement('div');
|
||||
effects.className = 'upgrade-btn__effects';
|
||||
const current = document.createElement('span');
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'upgrade-btn__arrow';
|
||||
arrow.textContent = '>';
|
||||
const next = document.createElement('span');
|
||||
effects.append(current, arrow, next);
|
||||
|
||||
const cost = document.createElement('div');
|
||||
cost.className = 'upgrade-btn__cost';
|
||||
cost.appendChild(pixelIcon(COIN_SPRITE, 16));
|
||||
const costValue = document.createElement('span');
|
||||
cost.appendChild(costValue);
|
||||
|
||||
btn.append(header, effects, cost);
|
||||
list.appendChild(btn);
|
||||
|
||||
return { def, btn, level, current, next, costValue };
|
||||
});
|
||||
|
||||
root.append(title, list);
|
||||
|
||||
const dispose = autorun(() => {
|
||||
for (const row of rows) {
|
||||
const level = store.upgradeLevels[row.def.id];
|
||||
const cost = costForNextLevel(row.def, level);
|
||||
row.btn.disabled = store.inventory.coins < cost;
|
||||
row.level.textContent = `Ур. ${level}`;
|
||||
row.current.textContent = row.def.formatEffect(row.def.effectAtLevel(level), level);
|
||||
row.next.textContent = row.def.formatEffect(row.def.effectAtLevel(level + 1), level + 1);
|
||||
row.costValue.textContent = String(cost);
|
||||
}
|
||||
});
|
||||
|
||||
return { el: root, dispose };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { autorun } from 'mobx';
|
||||
import { formatClock } from '../game/daynight';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { COIN_SPRITE, HEART_SPRITE, LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||
import { pixelIcon } from './pixelIcon';
|
||||
|
||||
export interface Mounted {
|
||||
el: HTMLElement;
|
||||
/** Stops the mobx autorun driving this element's text — call when the game screen unmounts. */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function slot(icon?: SVGSVGElement): { el: HTMLElement; text: HTMLSpanElement } {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'inventory-slot';
|
||||
if (icon)
|
||||
el.appendChild(icon);
|
||||
const text = document.createElement('span');
|
||||
el.appendChild(text);
|
||||
return { el, text };
|
||||
}
|
||||
|
||||
/** Builds the topbar (clock/hp/inventory/exit button), keeping its text in sync with the store via
|
||||
* a single mobx autorun — the DOM-side replacement for Topbar.tsx. */
|
||||
export function createTopbar(store: GameStore, onExitToMenu: () => void): Mounted {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'topbar';
|
||||
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'topbar__title';
|
||||
title.textContent = 'Forest';
|
||||
|
||||
const clock = slot();
|
||||
const hp = slot(pixelIcon(HEART_SPRITE, 18));
|
||||
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'spacer';
|
||||
|
||||
const twigs = slot(pixelIcon(TWIG_SPRITE, 22));
|
||||
const logs = slot(pixelIcon(LOG_SPRITE, 28));
|
||||
const meat = slot(pixelIcon(MEAT_SPRITE, 22));
|
||||
const coins = slot(pixelIcon(COIN_SPRITE, 24));
|
||||
|
||||
const exitBtn = document.createElement('button');
|
||||
exitBtn.type = 'button';
|
||||
exitBtn.className = 'exit-menu-btn';
|
||||
exitBtn.textContent = 'Выйти в меню';
|
||||
exitBtn.addEventListener('click', onExitToMenu);
|
||||
|
||||
root.append(
|
||||
title,
|
||||
clock.el,
|
||||
hp.el,
|
||||
spacer,
|
||||
twigs.el,
|
||||
logs.el,
|
||||
meat.el,
|
||||
coins.el,
|
||||
exitBtn,
|
||||
);
|
||||
|
||||
const dispose = autorun(() => {
|
||||
clock.text.textContent = `День ${store.day}, ${formatClock(store.hour)}`;
|
||||
hp.text.textContent = `${store.characterHp.toFixed(1)}/${store.characterMaxHp}`;
|
||||
twigs.text.textContent = String(store.inventory.twigs);
|
||||
logs.text.textContent = String(store.inventory.logs);
|
||||
meat.text.textContent = String(store.inventory.meat);
|
||||
coins.text.textContent = store.inventory.coins.toFixed(1);
|
||||
});
|
||||
|
||||
return { el: root, dispose };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PixelGrid } from '../game/sprites';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
/** Builds a small inline SVG for a PixelGrid (see game/sprites.ts) — the DOM-side replacement for
|
||||
* PixelSprite.tsx, used for the HUD's static icons (topbar/sidebar). The game world's own sprites
|
||||
* are baked into Pixi textures instead (see src/pixi/textures.ts); this is only for the handful of
|
||||
* fixed-size icons drawn in plain HTML. */
|
||||
export function pixelIcon(pixels: PixelGrid, size: number): SVGSVGElement {
|
||||
const rows = pixels.length;
|
||||
const cols = pixels[0]?.length ?? 0;
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||
svg.setAttribute('width', String(size));
|
||||
svg.setAttribute('height', String(cols > 0 ? size * (rows / cols) : size));
|
||||
svg.setAttribute('viewBox', `0 0 ${cols} ${rows}`);
|
||||
svg.setAttribute('shape-rendering', 'crispEdges');
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const color = pixels[y][x];
|
||||
if (!color)
|
||||
continue;
|
||||
const rect = document.createElementNS(SVG_NS, 'rect');
|
||||
rect.setAttribute('x', String(x));
|
||||
rect.setAttribute('y', String(y));
|
||||
rect.setAttribute('width', '1');
|
||||
rect.setAttribute('height', '1');
|
||||
rect.setAttribute('fill', color);
|
||||
svg.appendChild(rect);
|
||||
}
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,33 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { CHARACTER_SPRITE } from '../game/sprites';
|
||||
import HealthBar from './HealthBar';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
const CHARACTER_SIZE = 48;
|
||||
|
||||
const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="entity character-shadow"
|
||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
|
||||
/>
|
||||
<div
|
||||
className={`entity character ${store.isMoving ? 'character--walking' : ''} ${store.characterHitFlashTimer > 0 ? 'chopping' : ''}`}
|
||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
|
||||
>
|
||||
<PixelSprite pixels={CHARACTER_SPRITE} size={CHARACTER_SIZE} />
|
||||
</div>
|
||||
{store.characterHp < store.characterMaxHp && (
|
||||
<HealthBar
|
||||
x={store.character.x}
|
||||
y={store.character.y - CHARACTER_SIZE * 0.5 - 6}
|
||||
ratio={store.characterHp / store.characterMaxHp}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default CharacterEntity;
|
||||
@@ -1,225 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { PointerEvent } from 'react';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { overlayColorForHour } from '../game/daynight';
|
||||
import CharacterEntity from './CharacterEntity';
|
||||
import NodeEntity from './NodeEntity';
|
||||
import RabbitEntity from './RabbitEntity';
|
||||
import ResourceDropEntity from './ResourceDropEntity';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
onDeath: () => void;
|
||||
}
|
||||
|
||||
// Must mirror the @media rule in index.css that rotates #root into landscape on a portrait
|
||||
// touchscreen — when that rule is active, the browser paints everything rotated 90°, but
|
||||
// PointerEvent.clientX/clientY (and Element.getBoundingClientRect()) keep reporting the
|
||||
// physical, unrotated screen: physical x/y and physical width/height end up swapped relative to
|
||||
// the app's own (rotated) layout, and have to be converted back explicitly wherever they're used.
|
||||
const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)';
|
||||
|
||||
/** Russian plural agreement for "день" (day) after a count. */
|
||||
function dayWord(n: number): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11)
|
||||
return 'день';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
|
||||
return 'дня';
|
||||
return 'дней';
|
||||
}
|
||||
|
||||
const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
|
||||
return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
|
||||
});
|
||||
|
||||
const BloodMoonBanner = observer(function BloodMoonBanner({ store }: { store: GameStore }) {
|
||||
if (!store.bloodMoonBannerVisible)
|
||||
return null;
|
||||
return <div className="blood-moon-banner">КРОВАВАЯ ЛУНА!</div>;
|
||||
});
|
||||
|
||||
const GameOverOverlay = observer(function GameOverOverlay({
|
||||
store,
|
||||
onDeath,
|
||||
}: {
|
||||
store: GameStore;
|
||||
onDeath: () => void;
|
||||
}) {
|
||||
if (!store.isDead)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="game-over-overlay">
|
||||
<div className="game-over-overlay__message">
|
||||
Вы прожили {store.day} {dayWord(store.day)}, но вас поймали кролики
|
||||
</div>
|
||||
<div className="game-over-overlay__stats">
|
||||
<div>Пройдено: {Math.round(store.character.distanceTraveled)} px</div>
|
||||
<div>Срублено: {store.treesChoppedCount} деревьев, {store.bushesChoppedCount} кустов</div>
|
||||
<div>Поймано кроликов: {store.rabbitsCaughtCount}</div>
|
||||
<div>Заработано монет: {store.totalCoinsEarned.toFixed(1)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="menu-btn"
|
||||
// The whole map underneath (including this overlay) has a pointerdown handler that
|
||||
// captures the pointer and drives character movement — without stopping it here, that
|
||||
// handler fires first on bubble-up and swallows the tap before it becomes a click.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={onDeath}
|
||||
>
|
||||
В меню
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function GameMap({ store, onDeath }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const isRotatedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(ROTATE_QUERY);
|
||||
isRotatedRef.current = mql.matches;
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
isRotatedRef.current = e.matches;
|
||||
};
|
||||
mql.addEventListener('change', handleChange);
|
||||
return () => mql.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
// getBoundingClientRect() reports the physical (rotated) box, whose width/height are swapped
|
||||
// relative to the app's own logical layout — see ROTATE_QUERY above.
|
||||
const rect = container.getBoundingClientRect();
|
||||
const [width, height] = isRotatedRef.current ? [rect.height, rect.width] : [rect.width, rect.height];
|
||||
if (width > 0 && height > 0) {
|
||||
store.initialize(width, height);
|
||||
store.start();
|
||||
setReady(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
store.stop();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('blur', store.release);
|
||||
window.addEventListener('beforeunload', store.save);
|
||||
return () => {
|
||||
window.removeEventListener('blur', store.release);
|
||||
window.removeEventListener('beforeunload', store.save);
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
/** Finds the node or rabbit (if any) the pointer is currently over, by DOM hit-testing at its
|
||||
* client coordinates. elementFromPoint uses physical screen coordinates and already accounts
|
||||
* for any CSS transform, so this needs no rotation correction (unlike toLocalPoint below). */
|
||||
function targetAtPoint(clientX: number, clientY: number): { kind: 'node' | 'rabbit'; id: string } | null {
|
||||
const el = document.elementFromPoint(clientX, clientY);
|
||||
const nodeHit = el?.closest('[data-node-id]');
|
||||
if (nodeHit)
|
||||
return { kind: 'node', id: nodeHit.getAttribute('data-node-id')! };
|
||||
const rabbitHit = el?.closest('[data-rabbit-id]');
|
||||
if (rabbitHit)
|
||||
return { kind: 'rabbit', id: rabbitHit.getAttribute('data-rabbit-id')! };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Converts a pointer event's physical screen position into the map's own logical (x, y) —
|
||||
* i.e. what the game world uses for the character's target position. When the map is rotated
|
||||
* (see ROTATE_QUERY), physical x/y are swapped and one is mirrored relative to logical x/y, so
|
||||
* a plain subtraction of rect.left/rect.top would point the character the wrong way. */
|
||||
function toLocalPoint(e: PointerEvent<HTMLDivElement>, rect: DOMRect): { x: number; y: number } {
|
||||
const physicalX = e.clientX - rect.left;
|
||||
const physicalY = e.clientY - rect.top;
|
||||
if (isRotatedRef.current)
|
||||
return { x: physicalY, y: rect.width - physicalX };
|
||||
return { x: physicalX, y: physicalY };
|
||||
}
|
||||
|
||||
/** The character's action is purely a function of the current pointer state: if the primary
|
||||
* button isn't held, nothing happens; otherwise it heads for whatever's under the cursor right
|
||||
* now — a node to chop, or open ground to walk to. Shared by pointerdown/move/up so panning the
|
||||
* cursor across a node mid-drag starts/stops chopping it, and releasing the button stops it. */
|
||||
function followPointer(e: PointerEvent<HTMLDivElement>) {
|
||||
if ((e.buttons & 1) === 0) {
|
||||
store.release();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = targetAtPoint(e.clientX, e.clientY);
|
||||
if (target) {
|
||||
if (target.kind === 'node')
|
||||
store.holdNode(target.id);
|
||||
else
|
||||
store.holdRabbit(target.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing's under the cursor right now. Unlike a stationary tree/bush, a rabbit can jump out
|
||||
// from under an otherwise-still cursor entirely on its own — that shouldn't read as "the
|
||||
// player moved off the target" the way it would for a stationary node, or every autonomous
|
||||
// jump would silently drop the attack and send the character off to walk to empty ground
|
||||
// instead. Keep chasing the same rabbit until the player actually points at something else or
|
||||
// releases the button.
|
||||
if (store.heldRabbitId)
|
||||
return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const { x, y } = toLocalPoint(e, rect);
|
||||
store.moveTo(x, y);
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
followPointer(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-map"
|
||||
ref={containerRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={followPointer}
|
||||
onPointerUp={followPointer}
|
||||
onPointerCancel={store.release}
|
||||
>
|
||||
{ready && (
|
||||
<>
|
||||
{store.resourceDrops.map((drop) => (
|
||||
<ResourceDropEntity key={drop.id} drop={drop} />
|
||||
))}
|
||||
|
||||
{store.nodes.map((node) => (
|
||||
<NodeEntity key={node.id} node={node} store={store} />
|
||||
))}
|
||||
|
||||
{store.rabbits.map((rabbit) => (
|
||||
<RabbitEntity key={rabbit.id} rabbit={rabbit} store={store} />
|
||||
))}
|
||||
|
||||
<CharacterEntity store={store} />
|
||||
|
||||
<DayNightOverlay store={store} />
|
||||
|
||||
<BloodMoonBanner store={store} />
|
||||
|
||||
<GameOverOverlay store={store} onDeath={onDeath} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GameMap);
|
||||
@@ -1,10 +0,0 @@
|
||||
/** A small floating bar above an entity's sprite, anchored bottom-center at (x, yAboveSprite). */
|
||||
function HealthBar({ x, y, ratio }: { x: number; y: number; ratio: number }) {
|
||||
return (
|
||||
<div className="health-bar" style={{ left: x, top: y }}>
|
||||
<div className="health-bar__fill" style={{ width: `${Math.max(0, Math.min(1, ratio)) * 100}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HealthBar;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Analytics } from '../analytics';
|
||||
import { hasSave } from '../game/persistence';
|
||||
|
||||
interface Props {
|
||||
analytics: Analytics;
|
||||
onNewGame: () => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
function MainMenu({ analytics, 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);
|
||||
|
||||
// Fires once per mount, i.e. exactly once per menu visit regardless of how the player got here
|
||||
// (initial load, death, or "Выйти в меню").
|
||||
useEffect(() => {
|
||||
analytics.menu();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function handleNewGame() {
|
||||
analytics.button('new_game');
|
||||
onNewGame();
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
analytics.button('continue');
|
||||
onContinue();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-menu">
|
||||
<h1 className="main-menu__title">Forest</h1>
|
||||
<div className="main-menu__buttons">
|
||||
<button type="button" className="menu-btn" onClick={handleNewGame}>
|
||||
Новая игра
|
||||
</button>
|
||||
<button type="button" className="menu-btn" disabled={!canContinue} onClick={handleContinue}>
|
||||
Продолжить
|
||||
</button>
|
||||
</div>
|
||||
<div className="main-menu__version">v{__APP_VERSION__}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainMenu;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import type { ResourceNode } from '../game/entities/ResourceNode';
|
||||
import { BUSH_SPRITE, TREE_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
const NODE_SPRITES = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
|
||||
const NODE_SIZES = { tree: 64, bush: 40 };
|
||||
|
||||
const NodeEntity = observer(function NodeEntity({ node, store }: { node: ResourceNode; store: GameStore }) {
|
||||
const isChopping = node.id === store.heldNodeId && store.isChopping;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`entity ${node.kind}-shadow`} style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) - 1 }} />
|
||||
<div
|
||||
className={`entity ${node.kind} ${isChopping ? 'chopping' : ''}`}
|
||||
style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) }}
|
||||
data-node-id={node.id}
|
||||
>
|
||||
<PixelSprite pixels={NODE_SPRITES[node.kind]} size={NODE_SIZES[node.kind]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default NodeEntity;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import type { PixelGrid } from '../game/sprites';
|
||||
|
||||
interface Props {
|
||||
pixels: PixelGrid;
|
||||
size: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PixelSprite({ pixels, size, className }: Props) {
|
||||
const rows = pixels.length;
|
||||
const cols = pixels[0]?.length ?? 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={cols > 0 ? size * (rows / cols) : size}
|
||||
viewBox={`0 0 ${cols} ${rows}`}
|
||||
shapeRendering="crispEdges"
|
||||
>
|
||||
{pixels.flatMap((row, y) =>
|
||||
row.map((color, x) =>
|
||||
color ? <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={color} /> : null,
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// `pixels` is always one of the module-level sprite constants and `size` is a literal passed by
|
||||
// the caller, so both are reference/value-stable across re-renders — memoizing means an entity
|
||||
// re-rendering every frame for its position (left/top) doesn't also regenerate this sprite's
|
||||
// (potentially hundreds of) <rect> elements each time.
|
||||
export default memo(PixelSprite);
|
||||
@@ -1,33 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import type { Rabbit } from '../game/entities/Rabbit';
|
||||
import { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../game/sprites';
|
||||
import HealthBar from './HealthBar';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
const RABBIT_SIZE = 30;
|
||||
|
||||
const RabbitEntity = observer(function RabbitEntity({ rabbit, store }: { rabbit: Rabbit; store: GameStore }) {
|
||||
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="entity rabbit-shadow"
|
||||
style={{ left: rabbit.x, top: rabbit.y, width: size, height: size / 2, marginTop: size * 0.6 }}
|
||||
/>
|
||||
<div
|
||||
className={`entity rabbit ${rabbit.hitFlashTimer > 0 ? 'chopping' : ''}`}
|
||||
style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }}
|
||||
data-rabbit-id={rabbit.id}
|
||||
>
|
||||
<PixelSprite pixels={store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE} size={size} />
|
||||
</div>
|
||||
{rabbit.hp < store.rabbitMaxHp && (
|
||||
<HealthBar x={rabbit.x} y={rabbit.y - rabbit.z - size * 0.5 - 6} ratio={rabbit.hp / store.rabbitMaxHp} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default RabbitEntity;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { ResourceDrop } from '../game/entities/ResourceDrop';
|
||||
import { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
const DROP_SPRITES = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE };
|
||||
const DROP_SIZES = { log: 20, twig: 18, meat: 18 };
|
||||
|
||||
const ResourceDropEntity = observer(function ResourceDropEntity({ drop }: { drop: ResourceDrop }) {
|
||||
return (
|
||||
<div>
|
||||
<div className={`entity ${drop.resource}-shadow`} style={{ left: drop.x, top: drop.y }} />
|
||||
<div
|
||||
className={`entity ${drop.resource}`}
|
||||
style={{ left: drop.x, top: drop.y - drop.z, zIndex: Math.floor(drop.y - drop.z) }}
|
||||
>
|
||||
<PixelSprite pixels={DROP_SPRITES[drop.resource]} size={DROP_SIZES[drop.resource]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ResourceDropEntity;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { costForNextLevel, UPGRADE_LIST } from '../game/upgrades';
|
||||
import { COIN_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
}
|
||||
|
||||
function Sidebar({ store }: Props) {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<h2 className="sidebar__title">Апгрейды</h2>
|
||||
<div className="upgrade-list">
|
||||
{UPGRADE_LIST.map((def) => {
|
||||
const level = store.upgradeLevels[def.id];
|
||||
const cost = costForNextLevel(def, level);
|
||||
const canAfford = store.inventory.coins >= cost;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={def.id}
|
||||
type="button"
|
||||
className="upgrade-btn"
|
||||
disabled={!canAfford}
|
||||
onClick={() => store.buyUpgrade(def.id)}
|
||||
>
|
||||
<div className="upgrade-btn__header">
|
||||
<span className="upgrade-btn__name">{def.name}</span>
|
||||
<span className="upgrade-btn__level">Ур. {level}</span>
|
||||
</div>
|
||||
<div className="upgrade-btn__effects">
|
||||
<span>{def.formatEffect(def.effectAtLevel(level), level)}</span>
|
||||
<span className="upgrade-btn__arrow">></span>
|
||||
<span>{def.formatEffect(def.effectAtLevel(level + 1), level + 1)}</span>
|
||||
</div>
|
||||
<div className="upgrade-btn__cost">
|
||||
<PixelSprite pixels={COIN_SPRITE} size={16} />
|
||||
<span>{cost}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Sidebar);
|
||||
@@ -1,47 +0,0 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { formatClock } from '../game/daynight';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { COIN_SPRITE, HEART_SPRITE, LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
onExitToMenu: () => void;
|
||||
}
|
||||
|
||||
function Topbar({ store, onExitToMenu }: Props) {
|
||||
return (
|
||||
<div className="topbar">
|
||||
<h1 className="topbar__title">Forest</h1>
|
||||
<div className="inventory-slot">
|
||||
<span>День {store.day}, {formatClock(store.hour)}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={HEART_SPRITE} size={18} />
|
||||
<span>{store.characterHp.toFixed(1)}/{store.characterMaxHp}</span>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={TWIG_SPRITE} size={22} />
|
||||
<span>{store.inventory.twigs}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={LOG_SPRITE} size={28} />
|
||||
<span>{store.inventory.logs}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={MEAT_SPRITE} size={22} />
|
||||
<span>{store.inventory.meat}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={COIN_SPRITE} size={24} />
|
||||
<span>{store.inventory.coins.toFixed(1)}</span>
|
||||
</div>
|
||||
<button type="button" className="exit-menu-btn" onClick={onExitToMenu}>
|
||||
Выйти в меню
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Topbar);
|
||||
@@ -61,9 +61,9 @@ const OVERLAY_KEYFRAMES: OverlayKeyframe[] = [
|
||||
{ hour: 24, r: 10, g: 10, b: 35, a: 0.55 },
|
||||
];
|
||||
|
||||
/** The ambient darkness/tint overlay for the given time of day, as a CSS rgba() string —
|
||||
* interpolated piecewise-linearly between OVERLAY_KEYFRAMES. */
|
||||
export function overlayColorForHour(hour: number): string {
|
||||
/** The ambient darkness/tint overlay for the given time of day, as {r,g,b,a} (0-255 channels,
|
||||
* 0-1 alpha) — interpolated piecewise-linearly between OVERLAY_KEYFRAMES. */
|
||||
export function overlayColorComponentsForHour(hour: number): { r: number; g: number; b: number; a: number } {
|
||||
const h = ((hour % 24) + 24) % 24;
|
||||
let prev = OVERLAY_KEYFRAMES[0];
|
||||
let next = OVERLAY_KEYFRAMES[OVERLAY_KEYFRAMES.length - 1];
|
||||
@@ -78,9 +78,5 @@ export function overlayColorForHour(hour: number): string {
|
||||
const span = next.hour - prev.hour;
|
||||
const t = span === 0 ? 0 : (h - prev.hour) / span;
|
||||
const lerp = (a: number, b: number) => a + (b - a) * t;
|
||||
const r = lerp(prev.r, next.r);
|
||||
const g = lerp(prev.g, next.g);
|
||||
const b = lerp(prev.b, next.b);
|
||||
const a = lerp(prev.a, next.a);
|
||||
return `rgba(${r.toFixed(0)}, ${g.toFixed(0)}, ${b.toFixed(0)}, ${a.toFixed(3)})`;
|
||||
return { r: lerp(prev.r, next.r), g: lerp(prev.g, next.g), b: lerp(prev.b, next.b), a: lerp(prev.a, next.a) };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/* Self-hosted (see public/fonts) instead of Google Fonts' CDN — latin + cyrillic covers every
|
||||
* character the UI actually uses (Russian text, digits, Latin punctuation). */
|
||||
@font-face {
|
||||
font-family: 'Press Start 2P';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/press-start-2p-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Press Start 2P';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/press-start-2p-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
:root {
|
||||
--panel-bg: #3a2a1e;
|
||||
--panel-border: #1b1611;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import './index.css';
|
||||
import { App } from './app/App';
|
||||
|
||||
// Best-effort: the browser only grants this in contexts like fullscreen or an installed PWA, and
|
||||
// throws everywhere else — the CSS rotate fallback in index.css covers the rest.
|
||||
screen.orientation?.lock?.('landscape').catch(() => {});
|
||||
|
||||
new App(document.getElementById('root')!);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Best-effort: the browser only grants this in contexts like fullscreen or an installed PWA, and
|
||||
// throws everywhere else — the CSS rotate fallback in index.css covers the rest.
|
||||
screen.orientation?.lock?.('landscape').catch(() => {})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Application } from 'pixi.js';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { GameWorld } from './GameWorld';
|
||||
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 PixiJS Application for the game world (see GameWorld) 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 app = new Application();
|
||||
private world: 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 it 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);
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (width <= 0 || height <= 0)
|
||||
return;
|
||||
|
||||
await this.app.init({
|
||||
width,
|
||||
height,
|
||||
resizeTo: container,
|
||||
backgroundAlpha: 0,
|
||||
antialias: false,
|
||||
autoDensity: true,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
});
|
||||
this.app.canvas.style.display = 'block';
|
||||
this.app.canvas.style.touchAction = 'none';
|
||||
container.appendChild(this.app.canvas);
|
||||
|
||||
this.world = new GameWorld(this.app);
|
||||
this.world.resize(width, height);
|
||||
this.app.renderer.on('resize', (w: number, h: number) => this.world?.resize(w, h));
|
||||
|
||||
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.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.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.app.destroy(true, { children: true, texture: false });
|
||||
this.world = null;
|
||||
this.store = null;
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
private readonly handleOrientationChange = (e: MediaQueryListEvent) => {
|
||||
this.isRotated = e.matches;
|
||||
};
|
||||
|
||||
private readonly tick = () => {
|
||||
if (!this.world || !this.store)
|
||||
return;
|
||||
this.world.update(this.store, this.app.ticker.deltaMS / 1000);
|
||||
};
|
||||
|
||||
/** Converts a pointer event's physical screen position into the map's own logical (x, y) — see
|
||||
* ROTATE_QUERY above. */
|
||||
private toLocalPoint(clientX: number, clientY: number, rect: DOMRect): { x: number; y: number } {
|
||||
const physicalX = clientX - rect.left;
|
||||
const physicalY = clientY - rect.top;
|
||||
if (this.isRotated)
|
||||
return { x: physicalY, y: rect.width - physicalX };
|
||||
return { x: physicalX, y: physicalY };
|
||||
}
|
||||
|
||||
/** Finds the node or rabbit (if any) under the given logical point, by distance to its center —
|
||||
* the canvas-rendering equivalent of GameMap.tsx's elementFromPoint hit-test. Among overlapping
|
||||
* candidates, picks whichever has the largest y (drawn frontmost — see the zIndex depth-sort in
|
||||
* the entity views). */
|
||||
private targetAt(x: number, y: number): Target | null {
|
||||
const store = this.store!;
|
||||
let best: (Target & { y: number }) | null = null;
|
||||
|
||||
for (const node of store.nodes) {
|
||||
if (Math.hypot(node.x - x, node.y - y) <= NODE_CLICK_RADIUS[node.kind] && (!best || node.y > best.y))
|
||||
best = { kind: 'node', id: node.id, y: node.y };
|
||||
}
|
||||
|
||||
const radius = rabbitClickRadius(store);
|
||||
for (const rabbit of store.rabbits) {
|
||||
if (Math.hypot(rabbit.x - x, rabbit.y - y) <= radius && (!best || rabbit.y > best.y))
|
||||
best = { kind: 'rabbit', id: rabbit.id, y: rabbit.y };
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The character's action is purely a function of the current pointer state — shared by
|
||||
* pointerdown/move/up so panning the cursor across a node mid-drag starts/stops chopping it, and
|
||||
* releasing the button stops it. Mirrors GameMap.tsx's followPointer. */
|
||||
private readonly handlePointerMove = (e: PointerEvent) => {
|
||||
const store = this.store;
|
||||
if (!store)
|
||||
return;
|
||||
|
||||
if ((e.buttons & 1) === 0) {
|
||||
store.release();
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const { x, y } = this.toLocalPoint(e.clientX, e.clientY, rect);
|
||||
const target = this.targetAt(x, y);
|
||||
if (target) {
|
||||
if (target.kind === 'node')
|
||||
store.holdNode(target.id);
|
||||
else
|
||||
store.holdRabbit(target.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// A rabbit can jump out from under an otherwise-still cursor on its own — keep chasing it
|
||||
// until the player points at something else or releases the button (see GameStore).
|
||||
if (store.heldRabbitId)
|
||||
return;
|
||||
|
||||
store.moveTo(x, y);
|
||||
};
|
||||
|
||||
private readonly handlePointerDown = (e: PointerEvent) => {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
this.handlePointerMove(e);
|
||||
};
|
||||
|
||||
private readonly handlePointerCancel = () => {
|
||||
this.store?.release();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Container, Sprite, Texture, type Application } 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. The HUD (topbar/sidebar/menu/
|
||||
* death screen) is plain DOM, not part of this — see src/app. */
|
||||
export class GameWorld {
|
||||
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(app: Application) {
|
||||
this.entities.sortableChildren = true;
|
||||
// Overlay sits above health bars (9999 vs 500 in the original CSS z-indexes), tinting
|
||||
// everything underneath, including them.
|
||||
app.stage.addChild(this.entities, this.healthBars, this.overlay);
|
||||
this.overlay.eventMode = 'none';
|
||||
|
||||
this.nodesView = new NodesView(app, this.entities);
|
||||
this.dropsView = new DropsView(app, this.entities);
|
||||
this.rabbitsView = new RabbitsView(app, this.entities, this.healthBars);
|
||||
this.characterView = new CharacterView(app, this.entities, this.healthBars);
|
||||
}
|
||||
|
||||
/** Resizes the full-screen day/night tint to cover the current map size. */
|
||||
resize(width: number, height: number): void {
|
||||
this.overlay.width = width;
|
||||
this.overlay.height = height;
|
||||
}
|
||||
|
||||
update(store: GameStore, deltaSeconds: number): void {
|
||||
this.elapsedSeconds += deltaSeconds;
|
||||
this.nodesView.sync(store, this.elapsedSeconds);
|
||||
this.dropsView.sync(store);
|
||||
this.rabbitsView.sync(store, this.elapsedSeconds);
|
||||
this.characterView.sync(store, this.elapsedSeconds);
|
||||
|
||||
const { r, g, b, a } = overlayColorComponentsForHour(store.hour);
|
||||
this.overlay.tint = (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);
|
||||
this.overlay.alpha = a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Ticker-driven equivalents of App.css's `walk-bob` and `chop-shake` keyframe animations. Both
|
||||
* read a single continuously-running clock (seconds since the world started) rather than timing
|
||||
* from when the animation "started" (as CSS restarts it on class-toggle) — at these durations
|
||||
* (0.12-0.3s) the discontinuity from not resetting is imperceptible, and not tracking a per-entity
|
||||
* start time keeps every caller a one-line lookup. */
|
||||
|
||||
const WALK_BOB_DURATION = 0.3;
|
||||
const WALK_BOB_OFFSET_Y = -3;
|
||||
|
||||
/** Mirrors `@keyframes walk-bob` with `steps(2)`: holds at 0, then jumps to -3px for the second
|
||||
* half of each cycle. */
|
||||
export function walkBobOffsetY(elapsedSeconds: number): number {
|
||||
const phase = elapsedSeconds % WALK_BOB_DURATION;
|
||||
return phase < WALK_BOB_DURATION / 2 ? 0 : WALK_BOB_OFFSET_Y;
|
||||
}
|
||||
|
||||
export interface ShakeOffset {
|
||||
x: number;
|
||||
y: number;
|
||||
/** Radians. */
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
const CHOP_SHAKE_DURATION = 0.12;
|
||||
|
||||
// Mirrors `@keyframes chop-shake`'s five stops (0/25/50/75/100%); rotation in degrees here,
|
||||
// converted to radians in chopShakeOffset.
|
||||
const CHOP_KEYFRAMES: ShakeOffset[] = [
|
||||
{ x: 0, y: 0, rotation: 0 },
|
||||
{ x: -2, y: 1, rotation: -6 },
|
||||
{ x: 1, y: -2, rotation: 5 },
|
||||
{ x: -1, y: 1, rotation: -4 },
|
||||
{ x: 0, y: 0, rotation: 0 },
|
||||
];
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/** Mirrors `@keyframes chop-shake` (default ease timing, approximated as linear interpolation
|
||||
* between its stops — imperceptible at a 0.12s cycle). */
|
||||
export function chopShakeOffset(elapsedSeconds: number): ShakeOffset {
|
||||
const phase = ((elapsedSeconds % CHOP_SHAKE_DURATION) / CHOP_SHAKE_DURATION) * 4;
|
||||
const index = Math.floor(phase);
|
||||
const frac = phase - index;
|
||||
const a = CHOP_KEYFRAMES[index];
|
||||
const b = CHOP_KEYFRAMES[index + 1];
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * frac,
|
||||
y: a.y + (b.y - a.y) * frac,
|
||||
rotation: (a.rotation + (b.rotation - a.rotation) * frac) * DEG_TO_RAD,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Container, Sprite, type Application } 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 { applySpriteSize, getSpriteTexture } from '../textures';
|
||||
|
||||
const CHARACTER_SIZE = 48;
|
||||
// .entity.character-shadow: width 48 height 24, margin-top 32 (offset from character.x/y).
|
||||
const SHADOW_OFFSET_Y = 32;
|
||||
const SHADOW_RADIUS_X = 24;
|
||||
const SHADOW_RADIUS_Y = 12;
|
||||
// .health-bar sits `CHARACTER_SIZE * 0.5 + 6` px above the character's own position.
|
||||
const HEALTH_BAR_OFFSET_Y = CHARACTER_SIZE * 0.5 + 6;
|
||||
|
||||
/** 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(app: Application, entities: Container, healthBars: Container) {
|
||||
const info = getSpriteTexture(app, CHARACTER_SPRITE);
|
||||
this.sprite = new Sprite(info.texture);
|
||||
this.sprite.anchor.set(0.5);
|
||||
applySpriteSize(this.sprite, info, CHARACTER_SIZE);
|
||||
|
||||
entities.addChild(this.shadow, this.sprite);
|
||||
healthBars.addChild(this.healthBar);
|
||||
}
|
||||
|
||||
sync(store: GameStore, elapsedSeconds: number): void {
|
||||
const { x, y } = store.character;
|
||||
|
||||
this.shadow.position.set(x, y + SHADOW_OFFSET_Y);
|
||||
this.shadow.zIndex = y - 1;
|
||||
|
||||
this.sprite.zIndex = y;
|
||||
if (store.characterHitFlashTimer > 0) {
|
||||
const shake = chopShakeOffset(elapsedSeconds);
|
||||
this.sprite.position.set(x + shake.x, y + shake.y);
|
||||
this.sprite.rotation = shake.rotation;
|
||||
} else {
|
||||
const bobY = store.isMoving ? walkBobOffsetY(elapsedSeconds) : 0;
|
||||
this.sprite.position.set(x, y + bobY);
|
||||
this.sprite.rotation = 0;
|
||||
}
|
||||
|
||||
const damaged = store.characterHp < store.characterMaxHp;
|
||||
this.healthBar.visible = damaged;
|
||||
if (damaged) {
|
||||
updateHealthBar(this.healthBar, store.characterHp / store.characterMaxHp);
|
||||
setHealthBarPosition(this.healthBar, x, y - HEALTH_BAR_OFFSET_Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Container, Sprite, type Application } 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 { applySpriteSize, getSpriteTexture } from '../textures';
|
||||
|
||||
const DROP_SPRITES: Record<ResourceType, PixelGrid> = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE };
|
||||
const DROP_SIZES: Record<ResourceType, number> = { log: 20, twig: 18, meat: 18 };
|
||||
// .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 app: Application;
|
||||
private readonly entities: Container;
|
||||
|
||||
constructor(app: Application, entities: Container) {
|
||||
this.app = app;
|
||||
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(this.app, DROP_SPRITES[resource]);
|
||||
const sprite = new Sprite(info.texture);
|
||||
sprite.anchor.set(0.5);
|
||||
applySpriteSize(sprite, info, DROP_SIZES[resource]);
|
||||
|
||||
this.entities.addChild(shadow, sprite);
|
||||
return { shadow, sprite };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Container, Sprite, type Application } 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 { applySpriteSize, getSpriteTexture } from '../textures';
|
||||
|
||||
const NODE_SPRITES: Record<NodeKind, PixelGrid> = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
|
||||
const NODE_SIZES: Record<NodeKind, number> = { tree: 64, bush: 40 };
|
||||
// .entity.tree-shadow: width 64 height 32, margin (26, 0, 0, -11) -> center offset (-11, +26).
|
||||
// .entity.bush-shadow: width 40 height 20, margin (16, 0, 0, -6) -> center offset (-6, +16).
|
||||
const SHADOW_OFFSET: Record<NodeKind, { x: number; y: number; radiusX: number; radiusY: number }> = {
|
||||
tree: { x: -11, y: 26, radiusX: 32, radiusY: 16 },
|
||||
bush: { x: -6, y: 16, radiusX: 20, radiusY: 10 },
|
||||
};
|
||||
// .entity.tree/.bush click padding (padding: Npx; margin: -Npx) 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: NODE_SIZES.tree / 2 + 12,
|
||||
bush: NODE_SIZES.bush / 2 + 8,
|
||||
};
|
||||
|
||||
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 app: Application;
|
||||
private readonly entities: Container;
|
||||
|
||||
constructor(app: Application, entities: Container) {
|
||||
this.app = app;
|
||||
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(this.app, NODE_SPRITES[kind]);
|
||||
const sprite = new Sprite(info.texture);
|
||||
sprite.anchor.set(0.5);
|
||||
applySpriteSize(sprite, info, NODE_SIZES[kind]);
|
||||
|
||||
this.entities.addChild(shadow, sprite);
|
||||
const visual: NodeVisual = { shadow, sprite };
|
||||
return visual;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Container, Graphics, Sprite, type Application } 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 { applySpriteSize, getSpriteTexture } from '../textures';
|
||||
|
||||
const RABBIT_SIZE = 30;
|
||||
|
||||
/** 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 + 6;
|
||||
}
|
||||
|
||||
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 app: Application;
|
||||
private readonly entities: Container;
|
||||
private readonly healthBars: Container;
|
||||
|
||||
constructor(app: Application, entities: Container, healthBars: Container) {
|
||||
this.app = app;
|
||||
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;
|
||||
|
||||
if (visual.isNightSprite !== store.isNight) {
|
||||
const info = getSpriteTexture(this.app, store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE);
|
||||
visual.sprite.texture = info.texture;
|
||||
visual.isNightSprite = store.isNight;
|
||||
}
|
||||
applySpriteSize(visual.sprite, getSpriteTexture(this.app, store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE), size);
|
||||
|
||||
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 - 6);
|
||||
}
|
||||
}
|
||||
|
||||
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(this.app, RABBIT_SPRITE);
|
||||
const sprite = new Sprite(info.texture);
|
||||
sprite.anchor.set(0.5);
|
||||
applySpriteSize(sprite, info, RABBIT_SIZE);
|
||||
|
||||
const healthBar = createHealthBar();
|
||||
|
||||
this.entities.addChild(shadow, sprite);
|
||||
this.healthBars.addChild(healthBar);
|
||||
|
||||
return { shadow, sprite, healthBar, shadowSize: RABBIT_SIZE, isNightSprite: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
|
||||
/** Matches `.health-bar`/`.health-bar__fill` in App.css. */
|
||||
const WIDTH = 28;
|
||||
const HEIGHT = 5;
|
||||
|
||||
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: 1, color: 0x1b1611 });
|
||||
if (clamped > 0)
|
||||
g.rect(0, 0, WIDTH * clamped, HEIGHT).fill(0x4caf50);
|
||||
}
|
||||
|
||||
/** Positions a health bar so (x, yAboveSprite) is its bottom-center, same anchor as the original
|
||||
* CSS transform. */
|
||||
export function setHealthBarPosition(g: Graphics, x: number, yAboveSprite: number): void {
|
||||
g.position.set(x - WIDTH / 2, yAboveSprite - HEIGHT);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
|
||||
/** A soft ground ellipse under an entity, matching the `.entity.*-shadow` rules in App.css. */
|
||||
export function createShadow(radiusX: number, radiusY: number): Graphics {
|
||||
const g = new Graphics();
|
||||
drawShadow(g, radiusX, radiusY);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Redraws a shadow at a new size — only rabbits need this (they double in size during a blood
|
||||
* moon), everything else's shadow is drawn once at creation and never resized. */
|
||||
export function drawShadow(g: Graphics, radiusX: number, radiusY: number): void {
|
||||
g.clear();
|
||||
g.ellipse(0, 0, radiusX, radiusY).fill({ color: 0x000000, alpha: 0.35 });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Graphics, type Application, type Sprite, type 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 app's whole lifetime.
|
||||
const cache = new WeakMap<PixelGrid, SpriteTextureInfo>();
|
||||
|
||||
/** Bakes a PixelGrid (a per-cell CSS-color grid, see game/sprites.ts) into a GPU texture once,
|
||||
* reusing it for every sprite instance of that kind. `scaleMode: 'nearest'` keeps upscaled edges
|
||||
* crisp/blocky, matching the original SVG rects' `shapeRendering="crispEdges"`. */
|
||||
export function getSpriteTexture(app: Application, grid: PixelGrid): SpriteTextureInfo {
|
||||
const cached = cache.get(grid);
|
||||
if (cached)
|
||||
return cached;
|
||||
|
||||
const rows = grid.length;
|
||||
const cols = grid[0]?.length ?? 0;
|
||||
const g = new Graphics();
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const color = grid[y][x];
|
||||
if (color)
|
||||
g.rect(x, y, 1, 1).fill(color);
|
||||
}
|
||||
}
|
||||
|
||||
const texture = app.renderer.generateTexture({
|
||||
target: g,
|
||||
textureSourceOptions: { scaleMode: 'nearest' },
|
||||
});
|
||||
g.destroy();
|
||||
|
||||
const info: SpriteTextureInfo = { texture, cols, rows };
|
||||
cache.set(grid, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Sizes a sprite the same way PixelSprite.tsx did: `size` is the display width, height follows
|
||||
* the grid's own row/col aspect ratio. */
|
||||
export function applySpriteSize(sprite: Sprite, info: SpriteTextureInfo, size: number): void {
|
||||
sprite.width = size;
|
||||
sprite.height = info.cols > 0 ? size * (info.rows / info.cols) : size;
|
||||
}
|
||||
@@ -23,7 +23,6 @@
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
@@ -35,7 +34,7 @@ function restartOnVersionJsonChange(): Plugin {
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), restartOnVersionJsonChange()],
|
||||
plugins: [restartOnVersionJsonChange()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user