From a3f2f4eeef2c71a2c9d26a207da91d3af26177f9 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Wed, 9 Sep 2026 11:49:24 +0300 Subject: [PATCH] anaytics --- .env.example | 5 ++ .gitignore | 3 + package-lock.json | 7 ++ package.json | 4 +- scripts/play.ts | 9 +- scripts/simulate.ts | 57 ------------- src/App.css | 10 +++ src/App.tsx | 36 +++++++- src/analytics/AnalyticsTransport.ts | 9 ++ src/analytics/ConsoleAnalyticsTransport.ts | 30 +++++++ src/analytics/GameAnalyticsTransport.ts | 74 ++++++++++++++++ src/analytics/NullAnalyticsTransport.ts | 10 +++ src/analytics/index.ts | 98 ++++++++++++++++++++++ src/components/MainMenu.tsx | 28 ++++++- src/game/GameStore.ts | 37 +++++++- src/game/persistence.ts | 3 + src/vite-env.d.ts | 12 +++ tsconfig.app.json | 9 ++ typings/gameanalytics.d.ts | 85 +++++++++++++++++++ vite.config.ts | 34 +++++++- 20 files changed, 490 insertions(+), 70 deletions(-) create mode 100644 .env.example delete mode 100644 scripts/simulate.ts create mode 100644 src/analytics/AnalyticsTransport.ts create mode 100644 src/analytics/ConsoleAnalyticsTransport.ts create mode 100644 src/analytics/GameAnalyticsTransport.ts create mode 100644 src/analytics/NullAnalyticsTransport.ts create mode 100644 src/analytics/index.ts create mode 100644 src/vite-env.d.ts create mode 100644 typings/gameanalytics.d.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1d94bf1 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env.local (already gitignored via *.local) and fill in real values from +# your GameAnalytics dashboard. Analytics is skipped entirely (no SDK loaded, no network calls) +# if either is left unset. +VITE_GAMEANALYTICS_GAME_KEY= +VITE_GAMEANALYTICS_GAME_SECRET= diff --git a/.gitignore b/.gitignore index a547bf3..6c82535 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +.env +version.json diff --git a/package-lock.json b/package-lock.json index 06f2dc8..5ad030a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "break_eternity.js": "^2.1.3", + "gameanalytics": "^5.0.0", "mobx": "^7.0.3", "mobx-react-lite": "^5.0.3", "react": "^19.2.8", @@ -1232,6 +1233,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gameanalytics": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/gameanalytics/-/gameanalytics-5.0.0.tgz", + "integrity": "sha512-Hdns5ejXvAXGo8EVlavjNwaKnH+tBHJY83Vf2Cs04FuNPkKZ7HjWC9pB/MO1NnOVdzJFq37dbDGjyfxDC3iNzg==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/package.json b/package.json index 5bd405e..d861fed 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,12 @@ "lint": "oxlint", "preview": "vite preview", "sim": "tsx scripts/simulate.ts", - "play": "tsx scripts/play.ts" + "play": "tsx scripts/play.ts", + "version": "node -e \"const{execSync}=require('child_process');const{writeFileSync}=require('fs');writeFileSync('version.json',JSON.stringify(execSync('git describe --tags --always --dirty').toString().trim())+'\\n');\"" }, "dependencies": { "break_eternity.js": "^2.1.3", + "gameanalytics": "^5.0.0", "mobx": "^7.0.3", "mobx-react-lite": "^5.0.3", "react": "^19.2.8", diff --git a/scripts/play.ts b/scripts/play.ts index b6e2b68..93e6974 100644 --- a/scripts/play.ts +++ b/scripts/play.ts @@ -16,6 +16,8 @@ * Run with: npx tsx scripts/play.ts [tickSeconds] */ import readline from 'node:readline'; +import { Analytics, makeAnalyticsGameInfo } from '../src/analytics'; +import { ConsoleAnalyticsTransport } from '../src/analytics/ConsoleAnalyticsTransport'; import { bushesForDay, treesForDay } from '../src/game/constants'; import { formatClock } from '../src/game/daynight'; import { Rabbit } from '../src/game/entities/Rabbit'; @@ -33,7 +35,12 @@ const TICKS_PER_BATCH = 1000; /** How far past the nearest rabbit to aim while fleeing it. */ const FLEE_DISTANCE = 400; -const store = new GameStore(); +// Logs every analytics call to the console (see ConsoleAnalyticsTransport) instead of sending it +// anywhere — this is what actually lets `npm run play` be used to verify analytics behavior. +let store: GameStore; +const analytics = new Analytics(new ConsoleAnalyticsTransport(), () => makeAnalyticsGameInfo(store)); +analytics.start(); +store = new GameStore(analytics); store.initialize(MAP_WIDTH, MAP_HEIGHT); const startedAt = Date.now(); diff --git a/scripts/simulate.ts b/scripts/simulate.ts deleted file mode 100644 index 3d4e86b..0000000 --- a/scripts/simulate.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** Headless example: plays the game for many ticks with no browser/DOM involved at all — proves - * GameStore/its entities (see src/game/entities) can run standalone, and demonstrates measuring - * how often a player would need to act (pick a new target) per unit of simulated time. - * - * Run with: npx tsx scripts/simulate.ts [ticks] [tickSeconds] - */ -import { Rabbit } from '../src/game/entities/Rabbit'; -import { ResourceNode } from '../src/game/entities/ResourceNode'; -import { GameStore } from '../src/game/GameStore'; - -const totalTicks = Number(process.argv[2] ?? 1_000_000); -const tickSeconds = Number(process.argv[3] ?? 0.1); -const mapWidth = 1200; -const mapHeight = 800; - -const store = new GameStore(); -store.initialize(mapWidth, mapHeight); - -// A minimal bot policy standing in for a player: keep whatever target is currently held as long -// as it still exists; otherwise pick the nearest node or rabbit and hold it. Each such pick is -// counted as one "user action" — a mouse click choosing a new thing to interact with. -let actionCount = 0; - -for (let i = 0; i < totalTicks; i++) { - if (store.isDead) - break; - - const stillHoldingNode = store.heldNodeId !== null && store.nodes.some((node) => node.id === store.heldNodeId); - const stillHoldingRabbit = store.heldRabbitId !== null && store.rabbits.some((rabbit) => rabbit.id === store.heldRabbitId); - - if (!stillHoldingNode && !stillHoldingRabbit) { - const nearestNode = ResourceNode.findNearestToPosition(store.nodes, store.character); - const nearestRabbit = Rabbit.findNearestToPosition(store.rabbits, store.character); - const nodeDistance = nearestNode ? Math.hypot(nearestNode.x - store.character.x, nearestNode.y - store.character.y) : Infinity; - const rabbitDistance = nearestRabbit ? Math.hypot(nearestRabbit.x - store.character.x, nearestRabbit.y - store.character.y) : Infinity; - - if (nearestNode && nodeDistance <= rabbitDistance) { - store.holdNode(nearestNode.id); - actionCount++; - } else if (nearestRabbit) { - store.holdRabbit(nearestRabbit.id); - actionCount++; - } - } - - store.tick(tickSeconds); -} - -const simulatedSeconds = totalTicks * tickSeconds; -console.log(JSON.stringify({ - ticks: totalTicks, - simulatedDay: Number((store.day + store.hour / 24).toFixed(2)), - isDead: store.isDead, - inventory: store.inventory, - actionCount, - actionsPerSimulatedMinute: Number((actionCount / (simulatedSeconds / 60)).toFixed(3)), -}, null, 2)); diff --git a/src/App.css b/src/App.css index cc2e78c..e9afab1 100644 --- a/src/App.css +++ b/src/App.css @@ -52,6 +52,7 @@ } .main-menu { + position: relative; height: 100%; display: flex; flex-direction: column; @@ -61,6 +62,15 @@ background: var(--panel-bg); } +.main-menu__version { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + font-size: 10px; + color: var(--panel-border); +} + .main-menu__title { font-size: 32px; margin: 0; diff --git a/src/App.tsx b/src/App.tsx index 185a2ff..1b983f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,18 +4,48 @@ import MainMenu from './components/MainMenu'; import Sidebar from './components/Sidebar'; import Topbar from './components/Topbar'; import { GameStore } from './game/GameStore'; -import { clearSave } from './game/persistence'; +import { 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() { - const [store] = useState(() => new GameStore()); // Always starts on the menu, regardless of whether a save exists — loading straight into a // resumed game on every page (re)load isn't what was asked for. const [screen, setScreen] = useState('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'); } @@ -32,7 +62,7 @@ function App() { } if (screen === 'menu') { - return setScreen('game')} />; + return setScreen('game')} />; } return ( diff --git a/src/analytics/AnalyticsTransport.ts b/src/analytics/AnalyticsTransport.ts new file mode 100644 index 0000000..856072f --- /dev/null +++ b/src/analytics/AnalyticsTransport.ts @@ -0,0 +1,9 @@ +export abstract class AnalyticsTransport { + abstract init(data: { version: string, resourceTypes: string[], resourceCurrencies: string[] }): void; + + abstract event(data: { event: string, value?: number, [x: string]: unknown }): void; + abstract ui(data: { event: string, value?: number, [x: string]: unknown }): void; + abstract progress(data: { world: string | number, level: string | number, sublevel?: string | number, kind: 'start' | 'complete' | 'fail', score?: number, [x: string]: unknown }): void; + abstract transaction(data: { resourceType: string, resourceId: string, amount: number, action: 'gain' | 'spend', withCurrency: string, [x: string]: unknown }): void; + abstract error(data: { severity: 'error', message: string, [x: string]: unknown }): void; +} diff --git a/src/analytics/ConsoleAnalyticsTransport.ts b/src/analytics/ConsoleAnalyticsTransport.ts new file mode 100644 index 0000000..8309961 --- /dev/null +++ b/src/analytics/ConsoleAnalyticsTransport.ts @@ -0,0 +1,30 @@ +import { AnalyticsTransport } from './AnalyticsTransport'; + +/** Logs every analytics call to the console instead of sending it anywhere — lets `npm run play` + * (or any other manual/headless run) show exactly what the game engine would have tracked, + * without needing real GameAnalytics credentials or a network call. */ +export class ConsoleAnalyticsTransport extends AnalyticsTransport { + init(data: { version: string, resourceTypes: string[], resourceCurrencies: string[] }): void { + console.log('[analytics] init', data); + } + + event(data: { event: string, value?: number, [x: string]: unknown }): void { + console.log('[analytics] event', data); + } + + ui(data: { event: string, value?: number, [x: string]: unknown }): void { + console.log('[analytics] ui', data); + } + + progress(data: { world: string | number, level: string | number, sublevel?: string | number, kind: 'start' | 'complete' | 'fail', score?: number, [x: string]: unknown }): void { + console.log('[analytics] progress', data); + } + + transaction(data: { resourceType: string, resourceId: string, amount: number, action: 'gain' | 'spend', withCurrency: string, [x: string]: unknown }): void { + console.log('[analytics] transaction', data); + } + + error(data: { severity: 'error', message: string, [x: string]: unknown }): void { + console.log('[analytics] error', data); + } +} diff --git a/src/analytics/GameAnalyticsTransport.ts b/src/analytics/GameAnalyticsTransport.ts new file mode 100644 index 0000000..d93c157 --- /dev/null +++ b/src/analytics/GameAnalyticsTransport.ts @@ -0,0 +1,74 @@ +import { gameanalytics } from 'gameanalytics'; + +import { AnalyticsTransport } from './AnalyticsTransport'; + +// gameanalytics' type declarations don't expose these enums even though they exist at runtime, +// so they're mirrored here from the SDK's public values (see GameAnalytics.d.ts EGA* enums). +const EGAErrorSeverity = { Error: 4 } as const; +const EGAProgressionStatus = { Start: 1, Complete: 2, Fail: 3 } as const; +const EGAResourceFlowType = { Source: 1, Sink: 2 } as const; + +export class GameAnalyticsTransport extends AnalyticsTransport { + private readonly gameKey: string; + private readonly gameSecret: string; + + constructor(gameKey: string, gameSecret: string) { + super(); + this.gameKey = gameKey; + this.gameSecret = gameSecret; + } + + init(data: { + version: string, + resourceTypes: string[], + resourceCurrencies: string[], + }) { + if (import.meta.env.DEV) { + gameanalytics.GameAnalytics.setEnabledInfoLog(true); + gameanalytics.GameAnalytics.setEnabledEventSubmission(true); + } + gameanalytics.GameAnalytics.configureBuild(data.version); + gameanalytics.GameAnalytics.configureAvailableResourceCurrencies(data.resourceCurrencies); + gameanalytics.GameAnalytics.configureAvailableResourceItemTypes(data.resourceTypes); + // gameanalytics.GameAnalytics.configureAvailableCustomDimensions01(["easy", "normal", "hard"]); + gameanalytics.GameAnalytics.initialize(this.gameKey, this.gameSecret); + } + + ui(data: { event: string, value?: number, [x: string]: unknown }): void { + this.event({ ...data, event: 'UI:' + data.event }); + } + + progress(data: { world: string | number, level: string | number, sublevel?: string | number, kind: 'start' | 'complete' | 'fail', score?: number, [x: string]: unknown }): void { + const { kind, world, level, sublevel, score, ...params } = data; + gameanalytics.GameAnalytics.addProgressionEvent( + kind === 'start' ? EGAProgressionStatus.Start : (kind === 'complete' ? EGAProgressionStatus.Complete : EGAProgressionStatus.Fail), + '' + world, + '' + level, + '' + sublevel, + score, + params, + ) + } + + transaction(data: { resourceType: string, resourceId: string, amount: number, action: 'gain' | 'spend', withCurrency: string, [x: string]: unknown }): void { + const { resourceType, resourceId, amount, action, withCurrency, ...params } = data; + gameanalytics.GameAnalytics.addResourceEvent( + action === 'gain' ? EGAResourceFlowType.Source : EGAResourceFlowType.Sink, + withCurrency, + amount, + resourceType, + resourceId, + params, + ) + } + + event(data: { event: string, value?: number, [x: string]: unknown }): void { + const { event, value, ...params } = data; + gameanalytics.GameAnalytics.addDesignEvent('UI:' + event, value, params); + } + + error(data: { severity: 'error', message: string, [x: string]: unknown }): void { + const { severity, message, ...params } = data; + gameanalytics.GameAnalytics.addErrorEvent(EGAErrorSeverity.Error, message.slice(0, 8192), params); + } +} diff --git a/src/analytics/NullAnalyticsTransport.ts b/src/analytics/NullAnalyticsTransport.ts new file mode 100644 index 0000000..b1877e9 --- /dev/null +++ b/src/analytics/NullAnalyticsTransport.ts @@ -0,0 +1,10 @@ +import { AnalyticsTransport } from './AnalyticsTransport'; + +export class NullAnalyticsTransport extends AnalyticsTransport { + init(_data: { version: string, resourceTypes: string[], resourceCurrencies: string[] }): void {}; + event(_data: { event: string, value?: number, [x: string]: unknown }): void { } + ui(_data: { event: string, value?: number, [x: string]: unknown }): void { } + progress(_data: { world: string | number, level: string | number, sublevel?: string | number, kind: 'start' | 'complete' | 'fail', score?: number, [x: string]: unknown }): void { } + transaction(_data: { resourceType: string, resourceId: string, amount: number, action: 'gain' | 'spend', withCurrency: string, [x: string]: unknown }): void { } + error(_data: { severity: 'error', message: string, [x: string]: unknown }): void { } +} diff --git a/src/analytics/index.ts b/src/analytics/index.ts new file mode 100644 index 0000000..b0d5a83 --- /dev/null +++ b/src/analytics/index.ts @@ -0,0 +1,98 @@ +import type { AnalyticsTransport } from "./AnalyticsTransport"; + +import version from "../../version.json"; +import type { GameStore } from "../game/GameStore"; + +export type RuntimeGameInfo = { + playTime: number; // seconds + day: number; + dayTime: number; // hours +} + +export function makeAnalyticsGameInfo(store: GameStore): RuntimeGameInfo { + return { + playTime: store.playTimeSeconds, + day: store.day, + dayTime: store.hour, + }; +} + +export class Analytics { + private readonly transport: AnalyticsTransport; + private gameProfider: () => RuntimeGameInfo; + + constructor(transport: AnalyticsTransport, game: () => RuntimeGameInfo) { + this.transport = transport; + this.gameProfider = game; + } + + private get game(): RuntimeGameInfo { + return this.gameProfider(); + } + + start() { + this.transport.init({ + version, + resourceTypes: ['upgrade'], + resourceCurrencies: ['coin'], + }); + } + + buyUpgrade(updateId: string, level: number) { + this.transport.transaction({ + resourceType: 'progress', + resourceId: updateId, + action: 'gain', + withCurrency: 'coin', + amount: level, + game: this.game, + }); + } + + menu() { + this.ui(`menu:start`); + } + + button(id: string, params?: Record) { + this.ui(`button:${id}:click`, params); + } + + private ui(id: string, params?: Record, value?: number) { + this.transport.ui({ + event: id, + value, + game: this.game, + ...params, + }); + } + + startLevel(level: number) { + this.transport.progress({ + kind: 'start', + world: 'main', + level, + game: this.game, + }); + } + + completeLevel(level: number, score: number) { + this.transport.progress({ + kind: 'complete', + world: 'main', + level, + score, + game: this.game, + }); + } + + death(reason: string) { + this.transport.progress({ + kind: 'fail', + world: 'main', + level: this.game.day, + score: 0, + game: this.game, + reason, + }); + } +} \ No newline at end of file diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 7c2066a..2ffba3d 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -1,27 +1,47 @@ -import { useState } from 'react'; +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({ onNewGame, onContinue }: Props) { +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__}
); } diff --git a/src/game/GameStore.ts b/src/game/GameStore.ts index 681471e..41e0294 100644 --- a/src/game/GameStore.ts +++ b/src/game/GameStore.ts @@ -20,6 +20,7 @@ import { import { pickSpawnPositions } from './spawning'; import type { Inventory, ResourceType, Vec2 } from './types'; import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades'; +import type { Analytics } from '../analytics'; /** How often (seconds of played time) the game autosaves while running. */ const AUTOSAVE_INTERVAL = 2; @@ -77,7 +78,15 @@ export class GameStore { /** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes * down when coins are spent on upgrades. */ totalCoinsEarned = 0; + /** Real seconds elapsed this life (ticked deltaTime, not in-game clock time) — stops + * accumulating once dead, same as the other life stats above (tick() returns early then). */ + playTimeSeconds = 0; + /** playTimeSeconds' value at the moment the current day/level started — not persisted, so + * continuing a saved game restarts this day's timer from the moment of resuming rather than + * from when that day originally began (a minor approximation, not worth the extra save fields + * to avoid). Used to compute completeLevel()'s elapsed-seconds argument at the next dawn. */ + private levelStartPlayTime = 0; private coinProgress = 0; private saveProgress = 0; private lastTimestamp: number | null = null; @@ -88,7 +97,10 @@ export class GameStore { /** Map dimensions from the last initialize(), reused to grow each new day's nodes. */ private mapSize: { width: number; height: number } | null = null; - constructor() { + private readonly analytics: Analytics; + + constructor(analytics: Analytics) { + this.analytics = analytics; // autoBind so methods stay correctly bound when passed as bare references (event handlers, // e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`). // Deliberately no side effects (e.g. global event listener registration) here beyond @@ -96,7 +108,11 @@ export class GameStore { // initializer in dev, constructing (and discarding) an extra instance — any listener // registered in the constructor would leak and fire for that discarded instance too. Global // listeners like beforeunload belong in a useEffect instead (see GameMap), which StrictMode - // handles correctly via its mount/cleanup/remount cycle. + // handles correctly via its mount/cleanup/remount cycle. The same reasoning is why the caller + // must pass an already-built AnalyticsProvider (a module-level singleton — see App.tsx) + // rather than GameStore constructing a GameAnalyticsProvider itself: that class registers its + // own listeners and starts a session in its constructor, so double-invocation would leak a + // listener set and open two sessions. makeAutoObservable(this, {}, { autoBind: true }); } @@ -238,6 +254,7 @@ export class GameStore { this.inventory.coins -= cost; this.upgradeLevels[id] = level + 1; + this.analytics.buyUpgrade(id, level + 1); return true; } @@ -281,6 +298,8 @@ export class GameStore { this.bushesChoppedCount = save?.bushesChoppedCount ?? 0; this.rabbitsCaughtCount = save?.rabbitsCaughtCount ?? 0; this.totalCoinsEarned = save?.totalCoinsEarned ?? 0; + this.playTimeSeconds = save?.playTimeSeconds ?? 0; + this.levelStartPlayTime = this.playTimeSeconds; // Rabbits are restored/spawned independently of the world-state branch above so a save from // before rabbits existed still loads fine — it just gets a freshly spawned batch of them. @@ -333,6 +352,7 @@ export class GameStore { bushesChoppedCount: this.bushesChoppedCount, rabbitsCaughtCount: this.rabbitsCaughtCount, totalCoinsEarned: this.totalCoinsEarned, + playTimeSeconds: this.playTimeSeconds, }); } @@ -461,6 +481,7 @@ export class GameStore { if (this.isDead) return; + this.playTimeSeconds += deltaTime; this.updateClock(deltaTime); this.updateBloodMoon(deltaTime); this.updateMovementAndChopping(deltaTime); @@ -510,6 +531,7 @@ export class GameStore { this.characterHitFlashTimer = HIT_FLASH_DURATION; if (this.character.hp <= 0) { this.isDead = true; + this.analytics.death('killed_by_rabbit'); clearSave(); } } @@ -518,6 +540,7 @@ export class GameStore { * (upgradable) day speed otherwise — rolling over into a new day, which grows fresh plants and * rabbits up to the new day's caps, whenever it passes midnight. */ private updateClock(deltaTime: number) { + const wasNight = this.isNight; const speed = this.isNight ? NIGHT_SPEED : this.daySpeedHoursPerSecond; this.hour += speed * deltaTime; while (this.hour >= 24) { @@ -526,6 +549,16 @@ export class GameStore { this.growNewNodesForDay(); this.growRabbitsForDay(); } + // Edge-triggered on the night->day flip rather than tracking a "last announced day", so it + // fires exactly once per dawn regardless of day-rollover timing — and never for day 1's own + // dawn, since a fresh game starts at noon (no night->day transition has happened yet). + if (wasNight && !this.isNight) { + const completedDay = this.day - 1; + const elapsedSeconds = this.playTimeSeconds - this.levelStartPlayTime; + this.analytics.completeLevel(completedDay, elapsedSeconds); + this.levelStartPlayTime = this.playTimeSeconds; + this.analytics.startLevel(this.day); + } } /** Each rabbit independently cycles: pause (deciding its next action) → jump or strike diff --git a/src/game/persistence.ts b/src/game/persistence.ts index f7f7a82..f408e98 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -29,6 +29,9 @@ export interface SaveData { /** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes * down when coins are spent on upgrades. */ totalCoinsEarned?: number; + /** Real seconds elapsed this life (ticked deltaTime, not in-game clock time) — stops + * accumulating once dead, same as the other life stats above. */ + playTimeSeconds?: number; } function isValidSave(value: unknown): value is SaveData { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..99e0c58 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +/** package.json's "version" field, injected at build/dev-server-start time via vite.config.ts's + * `define` — a plain compile-time-replaced global, not import.meta.env, so no VITE_ prefix. */ +declare const __APP_VERSION__: string; + +interface ImportMetaEnv { + /** GameAnalytics game key — see .env.example. Analytics is skipped entirely (NullAnalyticsProvider) + * when this or VITE_GAMEANALYTICS_GAME_SECRET is unset. */ + readonly VITE_GAMEANALYTICS_GAME_KEY?: string; + readonly VITE_GAMEANALYTICS_GAME_SECRET?: string; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 6830b6f..c03dff9 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -7,6 +7,15 @@ "types": ["vite/client"], "allowArbitraryExtensions": true, "skipLibCheck": true, + "typeRoots": ["./typings"], + // The "gameanalytics" package's own shipped .d.ts doesn't compile under strict mode (invalid + // `module` keyword usage, inconsistent export modifiers — see typings/gameanalytics.d.ts's + // header for the full story) and typeRoots alone doesn't override a package's own real types, + // only add extra global ones — this path mapping is what actually redirects every import of + // "gameanalytics" to our corrected local file instead. + "paths": { + "gameanalytics": ["./typings/gameanalytics.d.ts"] + }, /* Bundler mode */ "moduleResolution": "bundler", diff --git a/typings/gameanalytics.d.ts b/typings/gameanalytics.d.ts new file mode 100644 index 0000000..9db42a3 --- /dev/null +++ b/typings/gameanalytics.d.ts @@ -0,0 +1,85 @@ +/** The npm package's own shipped types (node_modules/gameanalytics/dist/GameAnalytics.d.ts) don't + * compile under strict mode: `module gameanalytics { ... }` (repeated for every nested namespace) + * should be `namespace gameanalytics { ... }` (TS1540), and its export modifiers are inconsistent + * across the merged `gameanalytics` declaration, which TS rejects outright (TS2395) — so the + * declared `GameAnalytics` member becomes unreachable even in the shipped file's own eyes + * (TS2339). This file replaces those types entirely (an ambient `declare module "gameanalytics"` + * shadows the package's own types for any import of that specifier). + * + * It also fixes a real shape mismatch, not just a syntax one: dist/GameAnalytics.esm.js — the + * build "gameanalytics"'s package.json "exports" actually resolves to for `import` — runs + * `gameanalytics.GameAnalytics.init()` at load and rebinds its own top-level `GameAnalytics` to + * `gaCommand` before exporting, i.e. `export default GameAnalytics` ends up exporting that + * *command-queue function*, not the class. The class with real, individually-typed methods only + * exists on the separate named export `gameanalytics.GameAnalytics`. Concretely: + * + * import GameAnalytics from 'gameanalytics'; // (command: string, ...args) => void + * GameAnalytics('initialize', gameKey, gameSecret); + * + * import { gameanalytics } from 'gameanalytics'; // the real class, real methods + * gameanalytics.GameAnalytics.initialize(gameKey, gameSecret); + */ +declare module 'gameanalytics' { + export enum EGAErrorSeverity { Undefined = 0, Debug = 1, Info = 2, Warning = 3, Error = 4, Critical = 5 } + export enum EGAProgressionStatus { Undefined = 0, Start = 1, Complete = 2, Fail = 3 } + export enum EGAResourceFlowType { Undefined = 0, Source = 1, Sink = 2 } + export enum EGAAdAction { Undefined = 0, Clicked = 1, Show = 2, FailedShow = 3, RewardReceived = 4 } + export enum EGAAdError { Undefined = 0, Unknown = 1, Offline = 2, NoFill = 3, InternalError = 4, InvalidRequest = 5, UnableToPrecache = 6 } + export enum EGAAdType { Undefined = 0, Video = 1, RewardedVideo = 2, Playable = 3, Interstitial = 4, OfferWall = 5, Banner = 6 } + + export namespace gameanalytics { + export class GameAnalytics { + static init(): void; + static configureAvailableCustomDimensions01(customDimensions?: string[]): void; + static configureAvailableCustomDimensions02(customDimensions?: string[]): void; + static configureAvailableCustomDimensions03(customDimensions?: string[]): void; + static configureAvailableResourceCurrencies(resourceCurrencies?: string[]): void; + static configureAvailableResourceItemTypes(resourceItemTypes?: string[]): void; + static configureBuild(build?: string): void; + static configureSdkGameEngineVersion(sdkGameEngineVersion?: string): void; + static configureGameEngineVersion(gameEngineVersion?: string): void; + static configureUserId(uId?: string): void; + static setExtUserId(uId?: string): void; + static getExtUserId(): string; + static getUserId(): string; + static initialize(gameKey?: string, gameSecret?: string): void; + static addBusinessEvent(currency?: string, amount?: number, itemType?: string, itemId?: string, cartType?: string, customFields?: Record, mergeFields?: boolean): void; + static addResourceEvent(flowType?: EGAResourceFlowType, currency?: string, amount?: number, itemType?: string, itemId?: string, customFields?: Record, mergeFields?: boolean): void; + static addProgressionEvent(progressionStatus?: EGAProgressionStatus, progression01?: string, progression02?: string, progression03?: string, score?: number, customFields?: Record, mergeFields?: boolean): void; + static addDesignEvent(eventId: string, value?: number, customFields?: Record, mergeFields?: boolean): void; + static addErrorEvent(severity?: EGAErrorSeverity, message?: string, customFields?: Record, mergeFields?: boolean): void; + static addAdEventWithNoAdReason(adAction?: EGAAdAction, adType?: EGAAdType, adSdkName?: string, adPlacement?: string, noAdReason?: EGAAdError, customFields?: Record, mergeFields?: boolean): void; + static addAdEventWithDuration(adAction?: EGAAdAction, adType?: EGAAdType, adSdkName?: string, adPlacement?: string, duration?: number, customFields?: Record, mergeFields?: boolean): void; + static addAdEvent(adAction?: EGAAdAction, adType?: EGAAdType, adSdkName?: string, adPlacement?: string, customFields?: Record, mergeFields?: boolean): void; + static setEnabledInfoLog(flag?: boolean): void; + static setEnabledVerboseLog(flag?: boolean): void; + static setEnabledManualSessionHandling(flag?: boolean): void; + static setEnabledEventSubmission(flag?: boolean): void; + static enableHealthEvent(flag?: boolean): void; + static setCustomDimension01(dimension?: string): void; + static setCustomDimension02(dimension?: string): void; + static setCustomDimension03(dimension?: string): void; + static setGlobalCustomEventFields(customFields?: Record): void; + static setEventProcessInterval(intervalInSeconds: number): void; + static startSession(): void; + static endSession(): void; + static onStop(): void; + static onResume(): void; + static getRemoteConfigsValueAsString(key: string, defaultValue?: string): string; + static getRemoteConfigsValueAsJSON(key: string, defaultValue?: unknown): unknown; + static isRemoteConfigsReady(): boolean; + static addRemoteConfigsListener(listener: { onRemoteConfigsUpdated: () => void }): void; + static removeRemoteConfigsListener(listener: { onRemoteConfigsUpdated: () => void }): void; + static getRemoteConfigsContentAsString(): string; + static getABTestingId(): string; + static getABTestingVariantId(): string; + static addOnBeforeUnloadListener(listener: { onBeforeUnload: () => void }): void; + static removeOnBeforeUnloadListener(listener: { onBeforeUnload: () => void }): void; + } + } + + /** The default export — see the file header: this is the command-queue dispatcher, not the + * `GameAnalytics` class. Call as `GameAnalyticsCommand('methodName', ...args)`. */ + const GameAnalyticsCommand: (command: string, ...args: unknown[]) => void; + export default GameAnalyticsCommand; +} diff --git a/vite.config.ts b/vite.config.ts index 76293c3..b9ee1b9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,14 +1,44 @@ +import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import path from 'node:path' import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' const rootDir = path.dirname(fileURLToPath(import.meta.url)) const engineDir = path.resolve(rootDir, '../engine') +const versionPath = path.resolve(rootDir, 'version.json') + +// Read once per config evaluation — a dev-server restart (see restartOnVersionJsonChange below) +// re-imports this whole file, so a version bump is picked up by simply restarting, without any +// extra caching/invalidation logic here. +const version = JSON.parse(readFileSync(versionPath, 'utf-8')) as string; + +// version.json isn't part of the app's module graph (nothing imports it as JS), so editing it +// doesn't trigger HMR or Vite's own config-file-triggered restart — only vite.config.ts itself is +// watched for that. This plugin explicitly watches version.json too and forces a full restart on +// change, so __APP_VERSION__ (set from `version` below) picks up a version bump without having to +// stop/start `npm run dev` by hand. +function restartOnVersionJsonChange(): Plugin { + return { + name: 'restart-on-version-json-change', + configureServer(server) { + server.watcher.add(versionPath) + server.watcher.on('change', (file) => { + if (path.normalize(file) === path.normalize(versionPath)) { + server.config.logger.info('version.json changed, restarting to pick up __APP_VERSION__...', { clear: false }) + server.restart() + } + }) + }, + } +} // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), restartOnVersionJsonChange()], + define: { + __APP_VERSION__: JSON.stringify(version), + }, resolve: { // Follow the node_modules symlink to the real sibling directory, so Vite's dev server // watches and transforms the actual engine source files (not a frozen pre-bundled copy).