миграция на pixi

This commit is contained in:
azykov@mail.ru
2026-09-09 13:45:30 +03:00
parent a3f2f4eeef
commit 4e30c585d1
41 changed files with 1303 additions and 899 deletions
+1 -7
View File
@@ -8,15 +8,9 @@
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<title>forest</title> <title>forest</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+97 -99
View File
@@ -11,15 +11,10 @@
"break_eternity.js": "^2.1.3", "break_eternity.js": "^2.1.3",
"gameanalytics": "^5.0.0", "gameanalytics": "^5.0.0",
"mobx": "^7.0.3", "mobx": "^7.0.3",
"mobx-react-lite": "^5.0.3", "pixi.js": "^8.20.1"
"react": "^19.2.8",
"react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0", "oxlint": "^1.79.0",
"tsx": "^4.23.13", "tsx": "^4.23.13",
"typescript": "~6.0.2", "typescript": "~6.0.2",
@@ -813,6 +808,12 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
@@ -1075,6 +1076,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/earcut": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -1085,54 +1092,19 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/react": { "node_modules/@webgpu/types": {
"version": "19.2.18", "version": "0.1.72",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
"dev": true, "license": "BSD-3-Clause"
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
}, },
"node_modules/@types/react-dom": { "node_modules/@xmldom/xmldom": {
"version": "19.2.7", "version": "0.8.15",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": ">=10.0.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"oxc-transform-react": "^0.145.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"oxc-transform-react": {
"optional": true
}
} }
}, },
"node_modules/break_eternity.js": { "node_modules/break_eternity.js": {
@@ -1141,13 +1113,6 @@
"integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==", "integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1158,6 +1123,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -1200,6 +1171,12 @@
"@esbuild/win32-x64": "0.28.2" "@esbuild/win32-x64": "0.28.2"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1239,6 +1216,27 @@
"integrity": "sha512-Hdns5ejXvAXGo8EVlavjNwaKnH+tBHJY83Vf2Cs04FuNPkKZ7HjWC9pB/MO1NnOVdzJFq37dbDGjyfxDC3iNzg==", "integrity": "sha512-Hdns5ejXvAXGo8EVlavjNwaKnH+tBHJY83Vf2Cs04FuNPkKZ7HjWC9pB/MO1NnOVdzJFq37dbDGjyfxDC3iNzg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/gifuct-js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
"license": "MIT",
"dependencies": {
"js-binary-schema-parser": "^2.0.3"
}
},
"node_modules/ismobilejs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
"license": "MIT"
},
"node_modules/js-binary-schema-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
"license": "MIT"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -1510,20 +1508,6 @@
"url": "https://opencollective.com/mobx" "url": "https://opencollective.com/mobx"
} }
}, },
"node_modules/mobx-react-lite": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-5.0.3.tgz",
"integrity": "sha512-8qT/d/8Cg8ljt+heKhoFHNoqgedyeSGzdTixT0lgbFMMFlWfHUcXHZ8UD63eQi3ELjkxaH1VU07lXWhRwM0cVA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mobx"
},
"peerDependencies": {
"mobx": "^7.0.0",
"react": "^18 || ^19"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1612,6 +1602,32 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pixi.js": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.1.tgz",
"integrity": "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==",
"license": "MIT",
"workspaces": [
"examples",
"playground"
],
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/earcut": "^3.0.0",
"@webgpu/types": "^0.1.69",
"@xmldom/xmldom": "^0.8.15",
"earcut": "^3.0.2",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
"parse-svg-path": "^0.2.0",
"tiny-lru": "^11.4.7"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.28", "version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
@@ -1641,27 +1657,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
@@ -1696,12 +1691,6 @@
"@rolldown/binding-win32-x64-msvc": "1.2.7" "@rolldown/binding-win32-x64-msvc": "1.2.7"
} }
}, },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1712,6 +1701,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tiny-lru": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+1 -6
View File
@@ -16,15 +16,10 @@
"break_eternity.js": "^2.1.3", "break_eternity.js": "^2.1.3",
"gameanalytics": "^5.0.0", "gameanalytics": "^5.0.0",
"mobx": "^7.0.3", "mobx": "^7.0.3",
"mobx-react-lite": "^5.0.3", "pixi.js": "^8.20.1"
"react": "^19.2.8",
"react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0", "oxlint": "^1.79.0",
"tsx": "^4.23.13", "tsx": "^4.23.13",
"typescript": "~6.0.2", "typescript": "~6.0.2",
Binary file not shown.
Binary file not shown.
+11 -150
View File
@@ -209,132 +209,9 @@
user-select: none; user-select: none;
} }
.health-bar { /* Wraps the blood-moon banner and death screen, both absolutely positioned over the game canvas
position: absolute; * (see GameOverlays.ts) — pointer-events is re-enabled on .game-over-overlay below for its button. */
width: 28px; .game-overlays {
height: 5px;
transform: translate(-50%, -100%);
background: rgba(0, 0, 0, 0.5);
border: 1px solid #1b1611;
z-index: 500;
pointer-events: none;
}
.health-bar__fill {
height: 100%;
background: #4caf50;
transition: none;
}
.entity {
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
}
.entity.log-shadow {
width: 20px;
height: 10px;
margin: 8px 0px 0px 0px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.entity.twig-shadow {
width: 18px;
height: 9px;
margin: 7px 0px 0px 0px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.entity.tree-shadow {
width: 64px;
height: 32px;
border-radius: 50%;
margin: 26px 0px 0px -11px;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.entity.bush-shadow {
width: 40px;
height: 20px;
border-radius: 50%;
margin: 16px 0px 0px -6px;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.entity.character-shadow {
width: 48px;
height: 24px;
border-radius: 50%;
margin: 32px 0px 0px 0px;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.entity.rabbit-shadow {
width: 18px;
height: 9px;
border-radius: 50%;
margin: 32px 10px 0px 0px;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.entity.meat-shadow {
width: 18px;
height: 9px;
border-radius: 50%;
margin: 7px 0px 0px 0px;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.entity.tree {
pointer-events: auto;
cursor: pointer;
padding: 12px;
margin: -12px;
}
.entity.bush {
pointer-events: auto;
cursor: pointer;
padding: 8px;
margin: -8px;
}
.entity.rabbit {
pointer-events: auto;
cursor: pointer;
padding: 6px;
margin: -6px;
}
.entity.chopping svg {
animation: chop-shake 0.12s infinite;
}
.entity.character {
transition: none;
}
.entity.character.character--walking svg {
animation: walk-bob 0.3s infinite steps(2);
}
/* Same specificity as the walk-bob rule above (and placed after it), so the hit-shake still
* shows on the brief flash even if the character happens to be walking at that exact moment. */
.entity.character.chopping svg {
animation: chop-shake 0.12s infinite;
}
.day-night-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
@@ -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 { .game-over-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -376,6 +260,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 24px; gap: 24px;
pointer-events: auto;
} }
.game-over-overlay__message { .game-over-overlay__message {
@@ -395,27 +280,3 @@
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
@keyframes chop-shake {
0%, 100% {
transform: translate(0, 0) rotate(0deg);
}
25% {
transform: translate(-2px, 1px) rotate(-6deg);
}
50% {
transform: translate(1px, -2px) rotate(5deg);
}
75% {
transform: translate(-1px, 1px) rotate(-4deg);
}
}
@keyframes walk-bob {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-3px);
}
}
-79
View File
@@ -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;
+95
View File
@@ -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;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { autorun } from 'mobx';
import type { GameStore } from '../game/GameStore';
import type { Mounted } from './Topbar';
/** Russian plural agreement for "день" (day) after a count. */
function dayWord(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11)
return 'день';
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
return 'дня';
return 'дней';
}
/** Builds the "КРОВАВАЯ ЛУНА!" banner and the death screen, both absolutely positioned over the
* game map (siblings of the Pixi canvas — see App.ts) — the DOM-side replacement for the
* corresponding pieces of GameMap.tsx. Both stay hidden/empty until the store says otherwise, kept
* in sync via a single mobx autorun. */
export function createGameOverlays(store: GameStore, onDeath: () => void): Mounted {
const root = document.createElement('div');
root.className = 'game-overlays';
const banner = document.createElement('div');
banner.className = 'blood-moon-banner';
banner.textContent = 'КРОВАВАЯ ЛУНА!';
banner.hidden = true;
const gameOver = document.createElement('div');
gameOver.className = 'game-over-overlay';
gameOver.hidden = true;
const message = document.createElement('div');
message.className = 'game-over-overlay__message';
const stats = document.createElement('div');
stats.className = 'game-over-overlay__stats';
const distance = document.createElement('div');
const chopped = document.createElement('div');
const caught = document.createElement('div');
const coins = document.createElement('div');
stats.append(distance, chopped, caught, coins);
const menuBtn = document.createElement('button');
menuBtn.type = 'button';
menuBtn.className = 'menu-btn';
// The whole map underneath (including this overlay) has a pointerdown handler that captures the
// pointer and drives character movement — without stopping it here, that handler fires first on
// bubble-up and swallows the tap before it becomes a click (see GameCanvas).
menuBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
menuBtn.addEventListener('click', onDeath);
menuBtn.textContent = 'В меню';
gameOver.append(message, stats, menuBtn);
root.append(banner, gameOver);
const dispose = autorun(() => {
banner.hidden = !store.bloodMoonBannerVisible;
gameOver.hidden = !store.isDead;
if (!store.isDead)
return;
message.textContent = `Вы прожили ${store.day} ${dayWord(store.day)}, но вас поймали кролики`;
distance.textContent = `Пройдено: ${Math.round(store.character.distanceTraveled)} px`;
chopped.textContent = `Срублено: ${store.treesChoppedCount} деревьев, ${store.bushesChoppedCount} кустов`;
caught.textContent = `Поймано кроликов: ${store.rabbitsCaughtCount}`;
coins.textContent = `Заработано монет: ${store.totalCoinsEarned.toFixed(1)}`;
});
return { el: root, dispose };
}
+56
View File
@@ -0,0 +1,56 @@
import type { GameStore } from '../game/GameStore';
import { GameCanvas } from '../pixi/GameCanvas';
import { createGameOverlays } from './GameOverlays';
import { createSidebar } from './Sidebar';
import { createTopbar } from './Topbar';
/** Builds the "playing" screen (topbar, sidebar, game map) and wires up the Pixi canvas — the
* DOM-side replacement for App.tsx's game-screen JSX plus GameMap.tsx's own structure. */
export class GameScreen {
readonly el: HTMLElement;
private readonly canvas = new GameCanvas();
private readonly topbar: ReturnType<typeof createTopbar>;
private readonly sidebar: ReturnType<typeof createSidebar>;
private readonly overlays: ReturnType<typeof createGameOverlays>;
private readonly mapEl: HTMLElement;
private readonly store: GameStore;
constructor(store: GameStore, onExitToMenu: () => void, onDeath: () => void) {
this.el = document.createElement('div');
this.el.className = 'app';
this.topbar = createTopbar(store, onExitToMenu);
const mainRow = document.createElement('div');
mainRow.className = 'main-row';
this.sidebar = createSidebar(store);
const mapEl = document.createElement('div');
mapEl.className = 'game-map';
this.overlays = createGameOverlays(store, onDeath);
mapEl.appendChild(this.overlays.el);
mainRow.append(this.sidebar.el, mapEl);
this.el.append(this.topbar.el, mainRow);
this.mapEl = mapEl;
this.store = store;
}
/** Starts the Pixi canvas. Must be called only after `el` is attached to the document — the
* canvas is sized from the map div's clientWidth/Height, which is 0 on a detached element, so
* calling this from the constructor (before App.ts inserts `el` into the page) would silently
* measure a zero-size box and never create the canvas at all. */
mount(): void {
void this.canvas.mount(this.mapEl, this.store);
}
destroy(): void {
this.canvas.destroy();
this.topbar.dispose();
this.sidebar.dispose();
this.overlays.dispose();
}
}
+47
View File
@@ -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;
}
+72
View File
@@ -0,0 +1,72 @@
import { autorun } from 'mobx';
import type { GameStore } from '../game/GameStore';
import { COIN_SPRITE } from '../game/sprites';
import { costForNextLevel, UPGRADE_LIST } from '../game/upgrades';
import { pixelIcon } from './pixelIcon';
import type { Mounted } from './Topbar';
/** Builds the sidebar's upgrade list, keeping each button's level/effects/cost/disabled state in
* sync via a single mobx autorun — the DOM-side replacement for Sidebar.tsx. */
export function createSidebar(store: GameStore): Mounted {
const root = document.createElement('div');
root.className = 'sidebar';
const title = document.createElement('h2');
title.className = 'sidebar__title';
title.textContent = 'Апгрейды';
const list = document.createElement('div');
list.className = 'upgrade-list';
const rows = UPGRADE_LIST.map((def) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'upgrade-btn';
btn.addEventListener('click', () => store.buyUpgrade(def.id));
const header = document.createElement('div');
header.className = 'upgrade-btn__header';
const name = document.createElement('span');
name.className = 'upgrade-btn__name';
name.textContent = def.name;
const level = document.createElement('span');
level.className = 'upgrade-btn__level';
header.append(name, level);
const effects = document.createElement('div');
effects.className = 'upgrade-btn__effects';
const current = document.createElement('span');
const arrow = document.createElement('span');
arrow.className = 'upgrade-btn__arrow';
arrow.textContent = '>';
const next = document.createElement('span');
effects.append(current, arrow, next);
const cost = document.createElement('div');
cost.className = 'upgrade-btn__cost';
cost.appendChild(pixelIcon(COIN_SPRITE, 16));
const costValue = document.createElement('span');
cost.appendChild(costValue);
btn.append(header, effects, cost);
list.appendChild(btn);
return { def, btn, level, current, next, costValue };
});
root.append(title, list);
const dispose = autorun(() => {
for (const row of rows) {
const level = store.upgradeLevels[row.def.id];
const cost = costForNextLevel(row.def, level);
row.btn.disabled = store.inventory.coins < cost;
row.level.textContent = `Ур. ${level}`;
row.current.textContent = row.def.formatEffect(row.def.effectAtLevel(level), level);
row.next.textContent = row.def.formatEffect(row.def.effectAtLevel(level + 1), level + 1);
row.costValue.textContent = String(cost);
}
});
return { el: root, dispose };
}
+72
View File
@@ -0,0 +1,72 @@
import { autorun } from 'mobx';
import { formatClock } from '../game/daynight';
import type { GameStore } from '../game/GameStore';
import { COIN_SPRITE, HEART_SPRITE, LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
import { pixelIcon } from './pixelIcon';
export interface Mounted {
el: HTMLElement;
/** Stops the mobx autorun driving this element's text — call when the game screen unmounts. */
dispose: () => void;
}
function slot(icon?: SVGSVGElement): { el: HTMLElement; text: HTMLSpanElement } {
const el = document.createElement('div');
el.className = 'inventory-slot';
if (icon)
el.appendChild(icon);
const text = document.createElement('span');
el.appendChild(text);
return { el, text };
}
/** Builds the topbar (clock/hp/inventory/exit button), keeping its text in sync with the store via
* a single mobx autorun — the DOM-side replacement for Topbar.tsx. */
export function createTopbar(store: GameStore, onExitToMenu: () => void): Mounted {
const root = document.createElement('div');
root.className = 'topbar';
const title = document.createElement('h1');
title.className = 'topbar__title';
title.textContent = 'Forest';
const clock = slot();
const hp = slot(pixelIcon(HEART_SPRITE, 18));
const spacer = document.createElement('div');
spacer.className = 'spacer';
const twigs = slot(pixelIcon(TWIG_SPRITE, 22));
const logs = slot(pixelIcon(LOG_SPRITE, 28));
const meat = slot(pixelIcon(MEAT_SPRITE, 22));
const coins = slot(pixelIcon(COIN_SPRITE, 24));
const exitBtn = document.createElement('button');
exitBtn.type = 'button';
exitBtn.className = 'exit-menu-btn';
exitBtn.textContent = 'Выйти в меню';
exitBtn.addEventListener('click', onExitToMenu);
root.append(
title,
clock.el,
hp.el,
spacer,
twigs.el,
logs.el,
meat.el,
coins.el,
exitBtn,
);
const dispose = autorun(() => {
clock.text.textContent = `День ${store.day}, ${formatClock(store.hour)}`;
hp.text.textContent = `${store.characterHp.toFixed(1)}/${store.characterMaxHp}`;
twigs.text.textContent = String(store.inventory.twigs);
logs.text.textContent = String(store.inventory.logs);
meat.text.textContent = String(store.inventory.meat);
coins.text.textContent = store.inventory.coins.toFixed(1);
});
return { el: root, dispose };
}
+35
View File
@@ -0,0 +1,35 @@
import type { PixelGrid } from '../game/sprites';
const SVG_NS = 'http://www.w3.org/2000/svg';
/** Builds a small inline SVG for a PixelGrid (see game/sprites.ts) — the DOM-side replacement for
* PixelSprite.tsx, used for the HUD's static icons (topbar/sidebar). The game world's own sprites
* are baked into Pixi textures instead (see src/pixi/textures.ts); this is only for the handful of
* fixed-size icons drawn in plain HTML. */
export function pixelIcon(pixels: PixelGrid, size: number): SVGSVGElement {
const rows = pixels.length;
const cols = pixels[0]?.length ?? 0;
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(cols > 0 ? size * (rows / cols) : size));
svg.setAttribute('viewBox', `0 0 ${cols} ${rows}`);
svg.setAttribute('shape-rendering', 'crispEdges');
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const color = pixels[y][x];
if (!color)
continue;
const rect = document.createElementNS(SVG_NS, 'rect');
rect.setAttribute('x', String(x));
rect.setAttribute('y', String(y));
rect.setAttribute('width', '1');
rect.setAttribute('height', '1');
rect.setAttribute('fill', color);
svg.appendChild(rect);
}
}
return svg;
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

-33
View File
@@ -1,33 +0,0 @@
import { observer } from 'mobx-react-lite';
import type { GameStore } from '../game/GameStore';
import { CHARACTER_SPRITE } from '../game/sprites';
import HealthBar from './HealthBar';
import PixelSprite from './PixelSprite';
const CHARACTER_SIZE = 48;
const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) {
return (
<>
<div
className="entity character-shadow"
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
/>
<div
className={`entity character ${store.isMoving ? 'character--walking' : ''} ${store.characterHitFlashTimer > 0 ? 'chopping' : ''}`}
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
>
<PixelSprite pixels={CHARACTER_SPRITE} size={CHARACTER_SIZE} />
</div>
{store.characterHp < store.characterMaxHp && (
<HealthBar
x={store.character.x}
y={store.character.y - CHARACTER_SIZE * 0.5 - 6}
ratio={store.characterHp / store.characterMaxHp}
/>
)}
</>
);
});
export default CharacterEntity;
-225
View File
@@ -1,225 +0,0 @@
import { observer } from 'mobx-react-lite';
import { useEffect, useRef, useState } from 'react';
import type { PointerEvent } from 'react';
import type { GameStore } from '../game/GameStore';
import { overlayColorForHour } from '../game/daynight';
import CharacterEntity from './CharacterEntity';
import NodeEntity from './NodeEntity';
import RabbitEntity from './RabbitEntity';
import ResourceDropEntity from './ResourceDropEntity';
interface Props {
store: GameStore;
onDeath: () => void;
}
// Must mirror the @media rule in index.css that rotates #root into landscape on a portrait
// touchscreen — when that rule is active, the browser paints everything rotated 90°, but
// PointerEvent.clientX/clientY (and Element.getBoundingClientRect()) keep reporting the
// physical, unrotated screen: physical x/y and physical width/height end up swapped relative to
// the app's own (rotated) layout, and have to be converted back explicitly wherever they're used.
const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)';
/** Russian plural agreement for "день" (day) after a count. */
function dayWord(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11)
return 'день';
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
return 'дня';
return 'дней';
}
const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
});
const BloodMoonBanner = observer(function BloodMoonBanner({ store }: { store: GameStore }) {
if (!store.bloodMoonBannerVisible)
return null;
return <div className="blood-moon-banner">КРОВАВАЯ ЛУНА!</div>;
});
const GameOverOverlay = observer(function GameOverOverlay({
store,
onDeath,
}: {
store: GameStore;
onDeath: () => void;
}) {
if (!store.isDead)
return null;
return (
<div className="game-over-overlay">
<div className="game-over-overlay__message">
Вы прожили {store.day} {dayWord(store.day)}, но вас поймали кролики
</div>
<div className="game-over-overlay__stats">
<div>Пройдено: {Math.round(store.character.distanceTraveled)} px</div>
<div>Срублено: {store.treesChoppedCount} деревьев, {store.bushesChoppedCount} кустов</div>
<div>Поймано кроликов: {store.rabbitsCaughtCount}</div>
<div>Заработано монет: {store.totalCoinsEarned.toFixed(1)}</div>
</div>
<button
type="button"
className="menu-btn"
// The whole map underneath (including this overlay) has a pointerdown handler that
// captures the pointer and drives character movement — without stopping it here, that
// handler fires first on bubble-up and swallows the tap before it becomes a click.
onPointerDown={(e) => e.stopPropagation()}
onClick={onDeath}
>
В меню
</button>
</div>
);
});
function GameMap({ store, onDeath }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [ready, setReady] = useState(false);
const isRotatedRef = useRef(false);
useEffect(() => {
const mql = window.matchMedia(ROTATE_QUERY);
isRotatedRef.current = mql.matches;
const handleChange = (e: MediaQueryListEvent) => {
isRotatedRef.current = e.matches;
};
mql.addEventListener('change', handleChange);
return () => mql.removeEventListener('change', handleChange);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container)
return;
// getBoundingClientRect() reports the physical (rotated) box, whose width/height are swapped
// relative to the app's own logical layout — see ROTATE_QUERY above.
const rect = container.getBoundingClientRect();
const [width, height] = isRotatedRef.current ? [rect.height, rect.width] : [rect.width, rect.height];
if (width > 0 && height > 0) {
store.initialize(width, height);
store.start();
setReady(true);
}
return () => {
store.stop();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
window.addEventListener('blur', store.release);
window.addEventListener('beforeunload', store.save);
return () => {
window.removeEventListener('blur', store.release);
window.removeEventListener('beforeunload', store.save);
};
}, [store]);
/** Finds the node or rabbit (if any) the pointer is currently over, by DOM hit-testing at its
* client coordinates. elementFromPoint uses physical screen coordinates and already accounts
* for any CSS transform, so this needs no rotation correction (unlike toLocalPoint below). */
function targetAtPoint(clientX: number, clientY: number): { kind: 'node' | 'rabbit'; id: string } | null {
const el = document.elementFromPoint(clientX, clientY);
const nodeHit = el?.closest('[data-node-id]');
if (nodeHit)
return { kind: 'node', id: nodeHit.getAttribute('data-node-id')! };
const rabbitHit = el?.closest('[data-rabbit-id]');
if (rabbitHit)
return { kind: 'rabbit', id: rabbitHit.getAttribute('data-rabbit-id')! };
return null;
}
/** Converts a pointer event's physical screen position into the map's own logical (x, y) —
* i.e. what the game world uses for the character's target position. When the map is rotated
* (see ROTATE_QUERY), physical x/y are swapped and one is mirrored relative to logical x/y, so
* a plain subtraction of rect.left/rect.top would point the character the wrong way. */
function toLocalPoint(e: PointerEvent<HTMLDivElement>, rect: DOMRect): { x: number; y: number } {
const physicalX = e.clientX - rect.left;
const physicalY = e.clientY - rect.top;
if (isRotatedRef.current)
return { x: physicalY, y: rect.width - physicalX };
return { x: physicalX, y: physicalY };
}
/** The character's action is purely a function of the current pointer state: if the primary
* button isn't held, nothing happens; otherwise it heads for whatever's under the cursor right
* now — a node to chop, or open ground to walk to. Shared by pointerdown/move/up so panning the
* cursor across a node mid-drag starts/stops chopping it, and releasing the button stops it. */
function followPointer(e: PointerEvent<HTMLDivElement>) {
if ((e.buttons & 1) === 0) {
store.release();
return;
}
const target = targetAtPoint(e.clientX, e.clientY);
if (target) {
if (target.kind === 'node')
store.holdNode(target.id);
else
store.holdRabbit(target.id);
return;
}
// Nothing's under the cursor right now. Unlike a stationary tree/bush, a rabbit can jump out
// from under an otherwise-still cursor entirely on its own — that shouldn't read as "the
// player moved off the target" the way it would for a stationary node, or every autonomous
// jump would silently drop the attack and send the character off to walk to empty ground
// instead. Keep chasing the same rabbit until the player actually points at something else or
// releases the button.
if (store.heldRabbitId)
return;
const rect = e.currentTarget.getBoundingClientRect();
const { x, y } = toLocalPoint(e, rect);
store.moveTo(x, y);
}
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
e.currentTarget.setPointerCapture(e.pointerId);
followPointer(e);
}
return (
<div
className="game-map"
ref={containerRef}
onPointerDown={handlePointerDown}
onPointerMove={followPointer}
onPointerUp={followPointer}
onPointerCancel={store.release}
>
{ready && (
<>
{store.resourceDrops.map((drop) => (
<ResourceDropEntity key={drop.id} drop={drop} />
))}
{store.nodes.map((node) => (
<NodeEntity key={node.id} node={node} store={store} />
))}
{store.rabbits.map((rabbit) => (
<RabbitEntity key={rabbit.id} rabbit={rabbit} store={store} />
))}
<CharacterEntity store={store} />
<DayNightOverlay store={store} />
<BloodMoonBanner store={store} />
<GameOverOverlay store={store} onDeath={onDeath} />
</>
)}
</div>
);
}
export default observer(GameMap);
-10
View File
@@ -1,10 +0,0 @@
/** A small floating bar above an entity's sprite, anchored bottom-center at (x, yAboveSprite). */
function HealthBar({ x, y, ratio }: { x: number; y: number; ratio: number }) {
return (
<div className="health-bar" style={{ left: x, top: y }}>
<div className="health-bar__fill" style={{ width: `${Math.max(0, Math.min(1, ratio)) * 100}%` }} />
</div>
);
}
export default HealthBar;
-49
View File
@@ -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;
-27
View File
@@ -1,27 +0,0 @@
import { observer } from 'mobx-react-lite';
import type { GameStore } from '../game/GameStore';
import type { ResourceNode } from '../game/entities/ResourceNode';
import { BUSH_SPRITE, TREE_SPRITE } from '../game/sprites';
import PixelSprite from './PixelSprite';
const NODE_SPRITES = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
const NODE_SIZES = { tree: 64, bush: 40 };
const NodeEntity = observer(function NodeEntity({ node, store }: { node: ResourceNode; store: GameStore }) {
const isChopping = node.id === store.heldNodeId && store.isChopping;
return (
<div>
<div className={`entity ${node.kind}-shadow`} style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) - 1 }} />
<div
className={`entity ${node.kind} ${isChopping ? 'chopping' : ''}`}
style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) }}
data-node-id={node.id}
>
<PixelSprite pixels={NODE_SPRITES[node.kind]} size={NODE_SIZES[node.kind]} />
</div>
</div>
);
});
export default NodeEntity;
-35
View File
@@ -1,35 +0,0 @@
import { memo } from 'react';
import type { PixelGrid } from '../game/sprites';
interface Props {
pixels: PixelGrid;
size: number;
className?: string;
}
function PixelSprite({ pixels, size, className }: Props) {
const rows = pixels.length;
const cols = pixels[0]?.length ?? 0;
return (
<svg
className={className}
width={size}
height={cols > 0 ? size * (rows / cols) : size}
viewBox={`0 0 ${cols} ${rows}`}
shapeRendering="crispEdges"
>
{pixels.flatMap((row, y) =>
row.map((color, x) =>
color ? <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={color} /> : null,
),
)}
</svg>
);
}
// `pixels` is always one of the module-level sprite constants and `size` is a literal passed by
// the caller, so both are reference/value-stable across re-renders — memoizing means an entity
// re-rendering every frame for its position (left/top) doesn't also regenerate this sprite's
// (potentially hundreds of) <rect> elements each time.
export default memo(PixelSprite);
-33
View File
@@ -1,33 +0,0 @@
import { observer } from 'mobx-react-lite';
import type { GameStore } from '../game/GameStore';
import type { Rabbit } from '../game/entities/Rabbit';
import { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../game/sprites';
import HealthBar from './HealthBar';
import PixelSprite from './PixelSprite';
const RABBIT_SIZE = 30;
const RabbitEntity = observer(function RabbitEntity({ rabbit, store }: { rabbit: Rabbit; store: GameStore }) {
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
return (
<div>
<div
className="entity rabbit-shadow"
style={{ left: rabbit.x, top: rabbit.y, width: size, height: size / 2, marginTop: size * 0.6 }}
/>
<div
className={`entity rabbit ${rabbit.hitFlashTimer > 0 ? 'chopping' : ''}`}
style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }}
data-rabbit-id={rabbit.id}
>
<PixelSprite pixels={store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE} size={size} />
</div>
{rabbit.hp < store.rabbitMaxHp && (
<HealthBar x={rabbit.x} y={rabbit.y - rabbit.z - size * 0.5 - 6} ratio={rabbit.hp / store.rabbitMaxHp} />
)}
</div>
);
});
export default RabbitEntity;
-23
View File
@@ -1,23 +0,0 @@
import { observer } from 'mobx-react-lite';
import type { ResourceDrop } from '../game/entities/ResourceDrop';
import { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
import PixelSprite from './PixelSprite';
const DROP_SPRITES = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE };
const DROP_SIZES = { log: 20, twig: 18, meat: 18 };
const ResourceDropEntity = observer(function ResourceDropEntity({ drop }: { drop: ResourceDrop }) {
return (
<div>
<div className={`entity ${drop.resource}-shadow`} style={{ left: drop.x, top: drop.y }} />
<div
className={`entity ${drop.resource}`}
style={{ left: drop.x, top: drop.y - drop.z, zIndex: Math.floor(drop.y - drop.z) }}
>
<PixelSprite pixels={DROP_SPRITES[drop.resource]} size={DROP_SIZES[drop.resource]} />
</div>
</div>
);
});
export default ResourceDropEntity;
-50
View File
@@ -1,50 +0,0 @@
import { observer } from 'mobx-react-lite';
import type { GameStore } from '../game/GameStore';
import { costForNextLevel, UPGRADE_LIST } from '../game/upgrades';
import { COIN_SPRITE } from '../game/sprites';
import PixelSprite from './PixelSprite';
interface Props {
store: GameStore;
}
function Sidebar({ store }: Props) {
return (
<div className="sidebar">
<h2 className="sidebar__title">Апгрейды</h2>
<div className="upgrade-list">
{UPGRADE_LIST.map((def) => {
const level = store.upgradeLevels[def.id];
const cost = costForNextLevel(def, level);
const canAfford = store.inventory.coins >= cost;
return (
<button
key={def.id}
type="button"
className="upgrade-btn"
disabled={!canAfford}
onClick={() => store.buyUpgrade(def.id)}
>
<div className="upgrade-btn__header">
<span className="upgrade-btn__name">{def.name}</span>
<span className="upgrade-btn__level">Ур. {level}</span>
</div>
<div className="upgrade-btn__effects">
<span>{def.formatEffect(def.effectAtLevel(level), level)}</span>
<span className="upgrade-btn__arrow">&gt;</span>
<span>{def.formatEffect(def.effectAtLevel(level + 1), level + 1)}</span>
</div>
<div className="upgrade-btn__cost">
<PixelSprite pixels={COIN_SPRITE} size={16} />
<span>{cost}</span>
</div>
</button>
);
})}
</div>
</div>
);
}
export default observer(Sidebar);
-47
View File
@@ -1,47 +0,0 @@
import { observer } from 'mobx-react-lite';
import { formatClock } from '../game/daynight';
import type { GameStore } from '../game/GameStore';
import { COIN_SPRITE, HEART_SPRITE, LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
import PixelSprite from './PixelSprite';
interface Props {
store: GameStore;
onExitToMenu: () => void;
}
function Topbar({ store, onExitToMenu }: Props) {
return (
<div className="topbar">
<h1 className="topbar__title">Forest</h1>
<div className="inventory-slot">
<span>День {store.day}, {formatClock(store.hour)}</span>
</div>
<div className="inventory-slot">
<PixelSprite pixels={HEART_SPRITE} size={18} />
<span>{store.characterHp.toFixed(1)}/{store.characterMaxHp}</span>
</div>
<div className="spacer" />
<div className="inventory-slot">
<PixelSprite pixels={TWIG_SPRITE} size={22} />
<span>{store.inventory.twigs}</span>
</div>
<div className="inventory-slot">
<PixelSprite pixels={LOG_SPRITE} size={28} />
<span>{store.inventory.logs}</span>
</div>
<div className="inventory-slot">
<PixelSprite pixels={MEAT_SPRITE} size={22} />
<span>{store.inventory.meat}</span>
</div>
<div className="inventory-slot">
<PixelSprite pixels={COIN_SPRITE} size={24} />
<span>{store.inventory.coins.toFixed(1)}</span>
</div>
<button type="button" className="exit-menu-btn" onClick={onExitToMenu}>
Выйти в меню
</button>
</div>
);
}
export default observer(Topbar);
+4 -8
View File
@@ -61,9 +61,9 @@ const OVERLAY_KEYFRAMES: OverlayKeyframe[] = [
{ hour: 24, r: 10, g: 10, b: 35, a: 0.55 }, { hour: 24, r: 10, g: 10, b: 35, a: 0.55 },
]; ];
/** The ambient darkness/tint overlay for the given time of day, as a CSS rgba() string — /** The ambient darkness/tint overlay for the given time of day, as {r,g,b,a} (0-255 channels,
* interpolated piecewise-linearly between OVERLAY_KEYFRAMES. */ * 0-1 alpha) — interpolated piecewise-linearly between OVERLAY_KEYFRAMES. */
export function overlayColorForHour(hour: number): string { export function overlayColorComponentsForHour(hour: number): { r: number; g: number; b: number; a: number } {
const h = ((hour % 24) + 24) % 24; const h = ((hour % 24) + 24) % 24;
let prev = OVERLAY_KEYFRAMES[0]; let prev = OVERLAY_KEYFRAMES[0];
let next = OVERLAY_KEYFRAMES[OVERLAY_KEYFRAMES.length - 1]; let next = OVERLAY_KEYFRAMES[OVERLAY_KEYFRAMES.length - 1];
@@ -78,9 +78,5 @@ export function overlayColorForHour(hour: number): string {
const span = next.hour - prev.hour; const span = next.hour - prev.hour;
const t = span === 0 ? 0 : (h - prev.hour) / span; const t = span === 0 ? 0 : (h - prev.hour) / span;
const lerp = (a: number, b: number) => a + (b - a) * t; const lerp = (a: number, b: number) => a + (b - a) * t;
const r = lerp(prev.r, next.r); return { r: lerp(prev.r, next.r), g: lerp(prev.g, next.g), b: lerp(prev.b, next.b), a: lerp(prev.a, next.a) };
const g = lerp(prev.g, next.g);
const b = lerp(prev.b, next.b);
const a = lerp(prev.a, next.a);
return `rgba(${r.toFixed(0)}, ${g.toFixed(0)}, ${b.toFixed(0)}, ${a.toFixed(3)})`;
} }
+19
View File
@@ -1,3 +1,22 @@
/* Self-hosted (see public/fonts) instead of Google Fonts' CDN — latin + cyrillic covers every
* character the UI actually uses (Russian text, digits, Latin punctuation). */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/press-start-2p-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/press-start-2p-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root { :root {
--panel-bg: #3a2a1e; --panel-bg: #3a2a1e;
--panel-border: #1b1611; --panel-border: #1b1611;
+8
View File
@@ -0,0 +1,8 @@
import './index.css';
import { App } from './app/App';
// Best-effort: the browser only grants this in contexts like fullscreen or an installed PWA, and
// throws everywhere else — the CSS rotate fallback in index.css covers the rest.
screen.orientation?.lock?.('landscape').catch(() => {});
new App(document.getElementById('root')!);
-14
View File
@@ -1,14 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
// Best-effort: the browser only grants this in contexts like fullscreen or an installed PWA, and
// throws everywhere else — the CSS rotate fallback in index.css covers the rest.
screen.orientation?.lock?.('landscape').catch(() => {})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+176
View File
@@ -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();
};
}
+52
View File
@@ -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;
}
}
+51
View File
@@ -0,0 +1,51 @@
/** Ticker-driven equivalents of App.css's `walk-bob` and `chop-shake` keyframe animations. Both
* read a single continuously-running clock (seconds since the world started) rather than timing
* from when the animation "started" (as CSS restarts it on class-toggle) — at these durations
* (0.12-0.3s) the discontinuity from not resetting is imperceptible, and not tracking a per-entity
* start time keeps every caller a one-line lookup. */
const WALK_BOB_DURATION = 0.3;
const WALK_BOB_OFFSET_Y = -3;
/** Mirrors `@keyframes walk-bob` with `steps(2)`: holds at 0, then jumps to -3px for the second
* half of each cycle. */
export function walkBobOffsetY(elapsedSeconds: number): number {
const phase = elapsedSeconds % WALK_BOB_DURATION;
return phase < WALK_BOB_DURATION / 2 ? 0 : WALK_BOB_OFFSET_Y;
}
export interface ShakeOffset {
x: number;
y: number;
/** Radians. */
rotation: number;
}
const CHOP_SHAKE_DURATION = 0.12;
// Mirrors `@keyframes chop-shake`'s five stops (0/25/50/75/100%); rotation in degrees here,
// converted to radians in chopShakeOffset.
const CHOP_KEYFRAMES: ShakeOffset[] = [
{ x: 0, y: 0, rotation: 0 },
{ x: -2, y: 1, rotation: -6 },
{ x: 1, y: -2, rotation: 5 },
{ x: -1, y: 1, rotation: -4 },
{ x: 0, y: 0, rotation: 0 },
];
const DEG_TO_RAD = Math.PI / 180;
/** Mirrors `@keyframes chop-shake` (default ease timing, approximated as linear interpolation
* between its stops — imperceptible at a 0.12s cycle). */
export function chopShakeOffset(elapsedSeconds: number): ShakeOffset {
const phase = ((elapsedSeconds % CHOP_SHAKE_DURATION) / CHOP_SHAKE_DURATION) * 4;
const index = Math.floor(phase);
const frac = phase - index;
const a = CHOP_KEYFRAMES[index];
const b = CHOP_KEYFRAMES[index + 1];
return {
x: a.x + (b.x - a.x) * frac,
y: a.y + (b.y - a.y) * frac,
rotation: (a.rotation + (b.rotation - a.rotation) * frac) * DEG_TO_RAD,
};
}
+58
View File
@@ -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);
}
}
}
+76
View File
@@ -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 };
}
}
+91
View File
@@ -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;
}
}
+117
View File
@@ -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 };
}
}
+28
View File
@@ -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);
}
+15
View File
@@ -0,0 +1,15 @@
import { Graphics } from 'pixi.js';
/** A soft ground ellipse under an entity, matching the `.entity.*-shadow` rules in App.css. */
export function createShadow(radiusX: number, radiusY: number): Graphics {
const g = new Graphics();
drawShadow(g, radiusX, radiusY);
return g;
}
/** Redraws a shadow at a new size — only rabbits need this (they double in size during a blood
* moon), everything else's shadow is drawn once at creation and never resized. */
export function drawShadow(g: Graphics, radiusX: number, radiusY: number): void {
g.clear();
g.ellipse(0, 0, radiusX, radiusY).fill({ color: 0x000000, alpha: 0.35 });
}
+49
View File
@@ -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;
}
-1
View File
@@ -23,7 +23,6 @@
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx",
/* Linting */ /* Linting */
"noUnusedLocals": true, "noUnusedLocals": true,
+1 -2
View File
@@ -1,7 +1,6 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'node:path' import path from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig, type Plugin } from 'vite' import { defineConfig, type Plugin } from 'vite'
const rootDir = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.dirname(fileURLToPath(import.meta.url))
@@ -35,7 +34,7 @@ function restartOnVersionJsonChange(): Plugin {
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), restartOnVersionJsonChange()], plugins: [restartOnVersionJsonChange()],
define: { define: {
__APP_VERSION__: JSON.stringify(version), __APP_VERSION__: JSON.stringify(version),
}, },