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 0000000..3ba1443 Binary files /dev/null and b/public/fonts/press-start-2p-cyrillic.woff2 differ diff --git a/public/fonts/press-start-2p-latin.woff2 b/public/fonts/press-start-2p-latin.woff2 new file mode 100644 index 0000000..947a979 Binary files /dev/null and b/public/fonts/press-start-2p-latin.woff2 differ 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), },