From 4e30c585d19ea7345c2af3a78e5d2d6f514eb15b Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Wed, 9 Sep 2026 13:45:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B0=20pixi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 8 +- package-lock.json | 196 +++++++++--------- package.json | 7 +- public/fonts/press-start-2p-cyrillic.woff2 | Bin 0 -> 6864 bytes public/fonts/press-start-2p-latin.woff2 | Bin 0 -> 12512 bytes src/App.css | 161 +-------------- src/App.tsx | 79 -------- src/app/App.ts | 95 +++++++++ src/app/GameOverlays.ts | 71 +++++++ src/app/GameScreen.ts | 56 +++++ src/app/MainMenu.ts | 47 +++++ src/app/Sidebar.ts | 72 +++++++ src/app/Topbar.ts | 72 +++++++ src/app/pixelIcon.ts | 35 ++++ src/assets/react.svg | 1 - src/components/CharacterEntity.tsx | 33 --- src/components/GameMap.tsx | 225 --------------------- src/components/HealthBar.tsx | 10 - src/components/MainMenu.tsx | 49 ----- src/components/NodeEntity.tsx | 27 --- src/components/PixelSprite.tsx | 35 ---- src/components/RabbitEntity.tsx | 33 --- src/components/ResourceDropEntity.tsx | 23 --- src/components/Sidebar.tsx | 50 ----- src/components/Topbar.tsx | 47 ----- src/game/daynight.ts | 12 +- src/index.css | 19 ++ src/main.ts | 8 + src/main.tsx | 14 -- src/pixi/GameCanvas.ts | 176 ++++++++++++++++ src/pixi/GameWorld.ts | 52 +++++ src/pixi/animations.ts | 51 +++++ src/pixi/entities/CharacterView.ts | 58 ++++++ src/pixi/entities/DropsView.ts | 76 +++++++ src/pixi/entities/NodesView.ts | 91 +++++++++ src/pixi/entities/RabbitsView.ts | 117 +++++++++++ src/pixi/healthBar.ts | 28 +++ src/pixi/shadow.ts | 15 ++ src/pixi/textures.ts | 49 +++++ tsconfig.app.json | 1 - vite.config.ts | 3 +- 41 files changed, 1303 insertions(+), 899 deletions(-) create mode 100644 public/fonts/press-start-2p-cyrillic.woff2 create mode 100644 public/fonts/press-start-2p-latin.woff2 delete mode 100644 src/App.tsx create mode 100644 src/app/App.ts create mode 100644 src/app/GameOverlays.ts create mode 100644 src/app/GameScreen.ts create mode 100644 src/app/MainMenu.ts create mode 100644 src/app/Sidebar.ts create mode 100644 src/app/Topbar.ts create mode 100644 src/app/pixelIcon.ts delete mode 100644 src/assets/react.svg delete mode 100644 src/components/CharacterEntity.tsx delete mode 100644 src/components/GameMap.tsx delete mode 100644 src/components/HealthBar.tsx delete mode 100644 src/components/MainMenu.tsx delete mode 100644 src/components/NodeEntity.tsx delete mode 100644 src/components/PixelSprite.tsx delete mode 100644 src/components/RabbitEntity.tsx delete mode 100644 src/components/ResourceDropEntity.tsx delete mode 100644 src/components/Sidebar.tsx delete mode 100644 src/components/Topbar.tsx create mode 100644 src/main.ts delete mode 100644 src/main.tsx create mode 100644 src/pixi/GameCanvas.ts create mode 100644 src/pixi/GameWorld.ts create mode 100644 src/pixi/animations.ts create mode 100644 src/pixi/entities/CharacterView.ts create mode 100644 src/pixi/entities/DropsView.ts create mode 100644 src/pixi/entities/NodesView.ts create mode 100644 src/pixi/entities/RabbitsView.ts create mode 100644 src/pixi/healthBar.ts create mode 100644 src/pixi/shadow.ts create mode 100644 src/pixi/textures.ts diff --git a/index.html b/index.html index 4a972d5..723a33e 100644 --- a/index.html +++ b/index.html @@ -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" /> forest - - -
- + diff --git a/package-lock.json b/package-lock.json index 5ad030a..e96e8c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d861fed..5275719 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/fonts/press-start-2p-cyrillic.woff2 b/public/fonts/press-start-2p-cyrillic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3ba14437670f4237da3f53097b761e998096cc58 GIT binary patch literal 6864 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 Kh3W$GJpcfA>GfiWA% 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 literal 0 HcmV?d00001 diff --git a/src/App.css b/src/App.css index e9afab1..e308d94 100644 --- a/src/App.css +++ b/src/App.css @@ -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); - } -} diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 1b983f0..0000000 --- a/src/App.tsx +++ /dev/null @@ -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('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 setScreen('game')} />; - } - - return ( -
- -
- - -
-
- ); -} - -export default App; diff --git a/src/app/App.ts b/src/app/App.ts new file mode 100644 index 0000000..bcd2031 --- /dev/null +++ b/src/app/App.ts @@ -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; + } +} diff --git a/src/app/GameOverlays.ts b/src/app/GameOverlays.ts new file mode 100644 index 0000000..f486986 --- /dev/null +++ b/src/app/GameOverlays.ts @@ -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 }; +} diff --git a/src/app/GameScreen.ts b/src/app/GameScreen.ts new file mode 100644 index 0000000..47d96a5 --- /dev/null +++ b/src/app/GameScreen.ts @@ -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; + private readonly sidebar: ReturnType; + private readonly overlays: ReturnType; + 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(); + } +} diff --git a/src/app/MainMenu.ts b/src/app/MainMenu.ts new file mode 100644 index 0000000..85a83b3 --- /dev/null +++ b/src/app/MainMenu.ts @@ -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; +} diff --git a/src/app/Sidebar.ts b/src/app/Sidebar.ts new file mode 100644 index 0000000..77e2968 --- /dev/null +++ b/src/app/Sidebar.ts @@ -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 }; +} diff --git a/src/app/Topbar.ts b/src/app/Topbar.ts new file mode 100644 index 0000000..3f9f112 --- /dev/null +++ b/src/app/Topbar.ts @@ -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 }; +} diff --git a/src/app/pixelIcon.ts b/src/app/pixelIcon.ts new file mode 100644 index 0000000..40feffe --- /dev/null +++ b/src/app/pixelIcon.ts @@ -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; +} diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/CharacterEntity.tsx b/src/components/CharacterEntity.tsx deleted file mode 100644 index 7954a2d..0000000 --- a/src/components/CharacterEntity.tsx +++ /dev/null @@ -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 ( - <> -
-
0 ? 'chopping' : ''}`} - style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }} - > - -
- {store.characterHp < store.characterMaxHp && ( - - )} - - ); -}); - -export default CharacterEntity; diff --git a/src/components/GameMap.tsx b/src/components/GameMap.tsx deleted file mode 100644 index 1a7fb5e..0000000 --- a/src/components/GameMap.tsx +++ /dev/null @@ -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
; -}); - -const BloodMoonBanner = observer(function BloodMoonBanner({ store }: { store: GameStore }) { - if (!store.bloodMoonBannerVisible) - return null; - return
КРОВАВАЯ ЛУНА!
; -}); - -const GameOverOverlay = observer(function GameOverOverlay({ - store, - onDeath, -}: { - store: GameStore; - onDeath: () => void; -}) { - if (!store.isDead) - return null; - - return ( -
-
- Вы прожили {store.day} {dayWord(store.day)}, но вас поймали кролики -
-
-
Пройдено: {Math.round(store.character.distanceTraveled)} px
-
Срублено: {store.treesChoppedCount} деревьев, {store.bushesChoppedCount} кустов
-
Поймано кроликов: {store.rabbitsCaughtCount}
-
Заработано монет: {store.totalCoinsEarned.toFixed(1)}
-
- -
- ); -}); - -function GameMap({ store, onDeath }: Props) { - const containerRef = useRef(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, 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) { - 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) { - e.currentTarget.setPointerCapture(e.pointerId); - followPointer(e); - } - - return ( -
- {ready && ( - <> - {store.resourceDrops.map((drop) => ( - - ))} - - {store.nodes.map((node) => ( - - ))} - - {store.rabbits.map((rabbit) => ( - - ))} - - - - - - - - - - )} -
- ); -} - -export default observer(GameMap); diff --git a/src/components/HealthBar.tsx b/src/components/HealthBar.tsx deleted file mode 100644 index 48a58de..0000000 --- a/src/components/HealthBar.tsx +++ /dev/null @@ -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 ( -
-
-
- ); -} - -export default HealthBar; diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx deleted file mode 100644 index 2ffba3d..0000000 --- a/src/components/MainMenu.tsx +++ /dev/null @@ -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 ( -
-

Forest

-
- - -
-
v{__APP_VERSION__}
-
- ); -} - -export default MainMenu; diff --git a/src/components/NodeEntity.tsx b/src/components/NodeEntity.tsx deleted file mode 100644 index 9586e72..0000000 --- a/src/components/NodeEntity.tsx +++ /dev/null @@ -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 ( -
-
-
- -
-
- ); -}); - -export default NodeEntity; diff --git a/src/components/PixelSprite.tsx b/src/components/PixelSprite.tsx deleted file mode 100644 index f82c418..0000000 --- a/src/components/PixelSprite.tsx +++ /dev/null @@ -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 ( - 0 ? size * (rows / cols) : size} - viewBox={`0 0 ${cols} ${rows}`} - shapeRendering="crispEdges" - > - {pixels.flatMap((row, y) => - row.map((color, x) => - color ? : null, - ), - )} - - ); -} - -// `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) elements each time. -export default memo(PixelSprite); diff --git a/src/components/RabbitEntity.tsx b/src/components/RabbitEntity.tsx deleted file mode 100644 index 428eb33..0000000 --- a/src/components/RabbitEntity.tsx +++ /dev/null @@ -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 ( -
-
-
0 ? 'chopping' : ''}`} - style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }} - data-rabbit-id={rabbit.id} - > - -
- {rabbit.hp < store.rabbitMaxHp && ( - - )} -
- ); -}); - -export default RabbitEntity; diff --git a/src/components/ResourceDropEntity.tsx b/src/components/ResourceDropEntity.tsx deleted file mode 100644 index 74cca3c..0000000 --- a/src/components/ResourceDropEntity.tsx +++ /dev/null @@ -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 ( -
-
-
- -
-
- ); -}); - -export default ResourceDropEntity; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx deleted file mode 100644 index 1cf26ce..0000000 --- a/src/components/Sidebar.tsx +++ /dev/null @@ -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 ( -
-

Апгрейды

-
- {UPGRADE_LIST.map((def) => { - const level = store.upgradeLevels[def.id]; - const cost = costForNextLevel(def, level); - const canAfford = store.inventory.coins >= cost; - - return ( - - ); - })} -
-
- ); -} - -export default observer(Sidebar); diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx deleted file mode 100644 index 7838b1b..0000000 --- a/src/components/Topbar.tsx +++ /dev/null @@ -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 ( -
-

Forest

-
- День {store.day}, {formatClock(store.hour)} -
-
- - {store.characterHp.toFixed(1)}/{store.characterMaxHp} -
-
-
- - {store.inventory.twigs} -
-
- - {store.inventory.logs} -
-
- - {store.inventory.meat} -
-
- - {store.inventory.coins.toFixed(1)} -
- -
- ); -} - -export default observer(Topbar); diff --git a/src/game/daynight.ts b/src/game/daynight.ts index 2e271b3..9cd07d6 100644 --- a/src/game/daynight.ts +++ b/src/game/daynight.ts @@ -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) }; } diff --git a/src/index.css b/src/index.css index b80dc94..7ad66e2 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..b52ce19 --- /dev/null +++ b/src/main.ts @@ -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')!); diff --git a/src/main.tsx b/src/main.tsx deleted file mode 100644 index 4eb3897..0000000 --- a/src/main.tsx +++ /dev/null @@ -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( - - - , -) diff --git a/src/pixi/GameCanvas.ts b/src/pixi/GameCanvas.ts new file mode 100644 index 0000000..4ccf549 --- /dev/null +++ b/src/pixi/GameCanvas.ts @@ -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 { + 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(); + }; +} diff --git a/src/pixi/GameWorld.ts b/src/pixi/GameWorld.ts new file mode 100644 index 0000000..9c1e52a --- /dev/null +++ b/src/pixi/GameWorld.ts @@ -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; + } +} diff --git a/src/pixi/animations.ts b/src/pixi/animations.ts new file mode 100644 index 0000000..55abd94 --- /dev/null +++ b/src/pixi/animations.ts @@ -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, + }; +} diff --git a/src/pixi/entities/CharacterView.ts b/src/pixi/entities/CharacterView.ts new file mode 100644 index 0000000..0dca880 --- /dev/null +++ b/src/pixi/entities/CharacterView.ts @@ -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); + } + } +} diff --git a/src/pixi/entities/DropsView.ts b/src/pixi/entities/DropsView.ts new file mode 100644 index 0000000..75ec12a --- /dev/null +++ b/src/pixi/entities/DropsView.ts @@ -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 = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE }; +const DROP_SIZES: Record = { 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 = { log: 8, twig: 7, meat: 7 }; +const SHADOW_RADII: Record = { + log: { x: 10, y: 5 }, + twig: { x: 9, y: 4.5 }, + meat: { x: 9, y: 4.5 }, +}; + +interface DropVisual { + shadow: ReturnType; + 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(); + 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(); + + 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 }; + } +} diff --git a/src/pixi/entities/NodesView.ts b/src/pixi/entities/NodesView.ts new file mode 100644 index 0000000..100f874 --- /dev/null +++ b/src/pixi/entities/NodesView.ts @@ -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 = { tree: TREE_SPRITE, bush: BUSH_SPRITE }; +const NODE_SIZES: Record = { 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 = { + 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 = { + tree: NODE_SIZES.tree / 2 + 12, + bush: NODE_SIZES.bush / 2 + 8, +}; + +interface NodeVisual { + shadow: ReturnType; + 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(); + 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(); + + 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; + } +} diff --git a/src/pixi/entities/RabbitsView.ts b/src/pixi/entities/RabbitsView.ts new file mode 100644 index 0000000..e15c28b --- /dev/null +++ b/src/pixi/entities/RabbitsView.ts @@ -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(); + 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(); + 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 }; + } +} diff --git a/src/pixi/healthBar.ts b/src/pixi/healthBar.ts new file mode 100644 index 0000000..4239a1b --- /dev/null +++ b/src/pixi/healthBar.ts @@ -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); +} diff --git a/src/pixi/shadow.ts b/src/pixi/shadow.ts new file mode 100644 index 0000000..81fdf68 --- /dev/null +++ b/src/pixi/shadow.ts @@ -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 }); +} diff --git a/src/pixi/textures.ts b/src/pixi/textures.ts new file mode 100644 index 0000000..7b21657 --- /dev/null +++ b/src/pixi/textures.ts @@ -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(); + +/** 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; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index c03dff9..77a2d1b 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,7 +23,6 @@ "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, - "jsx": "react-jsx", /* Linting */ "noUnusedLocals": true, diff --git a/vite.config.ts b/vite.config.ts index b9ee1b9..ce7eb00 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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), },