anaytics
This commit is contained in:
@@ -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=
|
||||
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
version.json
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
+10
@@ -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;
|
||||
|
||||
+33
-3
@@ -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<Screen>('menu');
|
||||
|
||||
function handleNewGame() {
|
||||
// A resumable save existing means there's a live (not-dead) game being abandoned by starting
|
||||
// fresh instead of continuing it — record that as the last level's failure. A death already
|
||||
// records its own failure at the moment it happens (see GameStore.landRabbitStrike) and clears
|
||||
// the save there too, so this naturally doesn't double-fire right after a death.
|
||||
if (hasSave())
|
||||
analytics.death('restarted');
|
||||
clearSave();
|
||||
setScreen('game');
|
||||
}
|
||||
@@ -32,7 +62,7 @@ function App() {
|
||||
}
|
||||
|
||||
if (screen === 'menu') {
|
||||
return <MainMenu onNewGame={handleNewGame} onContinue={() => setScreen('game')} />;
|
||||
return <MainMenu analytics={analytics} onNewGame={handleNewGame} onContinue={() => setScreen('game')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
this.ui(`button:${id}:click`, params);
|
||||
}
|
||||
|
||||
private ui(id: string, params?: Record<string, unknown>, 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="main-menu">
|
||||
<h1 className="main-menu__title">Forest</h1>
|
||||
<div className="main-menu__buttons">
|
||||
<button type="button" className="menu-btn" onClick={onNewGame}>
|
||||
<button type="button" className="menu-btn" onClick={handleNewGame}>
|
||||
Новая игра
|
||||
</button>
|
||||
<button type="button" className="menu-btn" disabled={!canContinue} onClick={onContinue}>
|
||||
<button type="button" className="menu-btn" disabled={!canContinue} onClick={handleContinue}>
|
||||
Продолжить
|
||||
</button>
|
||||
</div>
|
||||
<div className="main-menu__version">v{__APP_VERSION__}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+35
-2
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Vendored
+85
@@ -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<string, unknown>, mergeFields?: boolean): void;
|
||||
static addResourceEvent(flowType?: EGAResourceFlowType, currency?: string, amount?: number, itemType?: string, itemId?: string, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addProgressionEvent(progressionStatus?: EGAProgressionStatus, progression01?: string, progression02?: string, progression03?: string, score?: number, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addDesignEvent(eventId: string, value?: number, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addErrorEvent(severity?: EGAErrorSeverity, message?: string, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addAdEventWithNoAdReason(adAction?: EGAAdAction, adType?: EGAAdType, adSdkName?: string, adPlacement?: string, noAdReason?: EGAAdError, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addAdEventWithDuration(adAction?: EGAAdAction, adType?: EGAAdType, adSdkName?: string, adPlacement?: string, duration?: number, customFields?: Record<string, unknown>, mergeFields?: boolean): void;
|
||||
static addAdEvent(adAction?: EGAAdAction, adType?: EGAAdType, adSdkName?: string, adPlacement?: string, customFields?: Record<string, unknown>, 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<string, unknown>): 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;
|
||||
}
|
||||
+32
-2
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user