This commit is contained in:
azykov@mail.ru
2026-09-09 11:49:24 +03:00
parent bd9b5d9aec
commit a3f2f4eeef
20 changed files with 490 additions and 70 deletions
+5
View File
@@ -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=
+3
View File
@@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.env
version.json
+7
View File
@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"break_eternity.js": "^2.1.3", "break_eternity.js": "^2.1.3",
"gameanalytics": "^5.0.0",
"mobx": "^7.0.3", "mobx": "^7.0.3",
"mobx-react-lite": "^5.0.3", "mobx-react-lite": "^5.0.3",
"react": "^19.2.8", "react": "^19.2.8",
@@ -1232,6 +1233,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "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": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+3 -1
View File
@@ -9,10 +9,12 @@
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview", "preview": "vite preview",
"sim": "tsx scripts/simulate.ts", "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": { "dependencies": {
"break_eternity.js": "^2.1.3", "break_eternity.js": "^2.1.3",
"gameanalytics": "^5.0.0",
"mobx": "^7.0.3", "mobx": "^7.0.3",
"mobx-react-lite": "^5.0.3", "mobx-react-lite": "^5.0.3",
"react": "^19.2.8", "react": "^19.2.8",
+8 -1
View File
@@ -16,6 +16,8 @@
* Run with: npx tsx scripts/play.ts [tickSeconds] * Run with: npx tsx scripts/play.ts [tickSeconds]
*/ */
import readline from 'node:readline'; 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 { bushesForDay, treesForDay } from '../src/game/constants';
import { formatClock } from '../src/game/daynight'; import { formatClock } from '../src/game/daynight';
import { Rabbit } from '../src/game/entities/Rabbit'; 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. */ /** How far past the nearest rabbit to aim while fleeing it. */
const FLEE_DISTANCE = 400; 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); store.initialize(MAP_WIDTH, MAP_HEIGHT);
const startedAt = Date.now(); const startedAt = Date.now();
-57
View File
@@ -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
View File
@@ -52,6 +52,7 @@
} }
.main-menu { .main-menu {
position: relative;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -61,6 +62,15 @@
background: var(--panel-bg); 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 { .main-menu__title {
font-size: 32px; font-size: 32px;
margin: 0; margin: 0;
+33 -3
View File
@@ -4,18 +4,48 @@ import MainMenu from './components/MainMenu';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import Topbar from './components/Topbar'; import Topbar from './components/Topbar';
import { GameStore } from './game/GameStore'; import { GameStore } from './game/GameStore';
import { clearSave } from './game/persistence'; import { clearSave, hasSave } from './game/persistence';
import './App.css'; 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'; 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() { function App() {
const [store] = useState(() => new GameStore());
// Always starts on the menu, regardless of whether a save exists — loading straight into a // 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. // resumed game on every page (re)load isn't what was asked for.
const [screen, setScreen] = useState<Screen>('menu'); const [screen, setScreen] = useState<Screen>('menu');
function handleNewGame() { 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(); clearSave();
setScreen('game'); setScreen('game');
} }
@@ -32,7 +62,7 @@ function App() {
} }
if (screen === 'menu') { if (screen === 'menu') {
return <MainMenu onNewGame={handleNewGame} onContinue={() => setScreen('game')} />; return <MainMenu analytics={analytics} onNewGame={handleNewGame} onContinue={() => setScreen('game')} />;
} }
return ( return (
+9
View File
@@ -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);
}
}
+74
View File
@@ -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);
}
}
+10
View File
@@ -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 { }
}
+98
View File
@@ -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,
});
}
}
+24 -4
View File
@@ -1,27 +1,47 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import type { Analytics } from '../analytics';
import { hasSave } from '../game/persistence'; import { hasSave } from '../game/persistence';
interface Props { interface Props {
analytics: Analytics;
onNewGame: () => void; onNewGame: () => void;
onContinue: () => 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 // 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. // App), so this naturally stays in sync with whether a save exists.
const [canContinue] = useState(hasSave); 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 ( return (
<div className="main-menu"> <div className="main-menu">
<h1 className="main-menu__title">Forest</h1> <h1 className="main-menu__title">Forest</h1>
<div className="main-menu__buttons"> <div className="main-menu__buttons">
<button type="button" className="menu-btn" onClick={onNewGame}> <button type="button" className="menu-btn" onClick={handleNewGame}>
Новая игра Новая игра
</button> </button>
<button type="button" className="menu-btn" disabled={!canContinue} onClick={onContinue}> <button type="button" className="menu-btn" disabled={!canContinue} onClick={handleContinue}>
Продолжить Продолжить
</button> </button>
</div> </div>
<div className="main-menu__version">v{__APP_VERSION__}</div>
</div> </div>
); );
} }
+35 -2
View File
@@ -20,6 +20,7 @@ import {
import { pickSpawnPositions } from './spawning'; import { pickSpawnPositions } from './spawning';
import type { Inventory, ResourceType, Vec2 } from './types'; import type { Inventory, ResourceType, Vec2 } from './types';
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades'; import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
import type { Analytics } from '../analytics';
/** How often (seconds of played time) the game autosaves while running. */ /** How often (seconds of played time) the game autosaves while running. */
const AUTOSAVE_INTERVAL = 2; const AUTOSAVE_INTERVAL = 2;
@@ -77,7 +78,15 @@ export class GameStore {
/** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes /** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes
* down when coins are spent on upgrades. */ * down when coins are spent on upgrades. */
totalCoinsEarned = 0; 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 coinProgress = 0;
private saveProgress = 0; private saveProgress = 0;
private lastTimestamp: number | null = null; 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. */ /** Map dimensions from the last initialize(), reused to grow each new day's nodes. */
private mapSize: { width: number; height: number } | null = null; 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, // autoBind so methods stay correctly bound when passed as bare references (event handlers,
// e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`). // e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`).
// Deliberately no side effects (e.g. global event listener registration) here beyond // 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 // 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 // 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 // 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 }); makeAutoObservable(this, {}, { autoBind: true });
} }
@@ -238,6 +254,7 @@ export class GameStore {
this.inventory.coins -= cost; this.inventory.coins -= cost;
this.upgradeLevels[id] = level + 1; this.upgradeLevels[id] = level + 1;
this.analytics.buyUpgrade(id, level + 1);
return true; return true;
} }
@@ -281,6 +298,8 @@ export class GameStore {
this.bushesChoppedCount = save?.bushesChoppedCount ?? 0; this.bushesChoppedCount = save?.bushesChoppedCount ?? 0;
this.rabbitsCaughtCount = save?.rabbitsCaughtCount ?? 0; this.rabbitsCaughtCount = save?.rabbitsCaughtCount ?? 0;
this.totalCoinsEarned = save?.totalCoinsEarned ?? 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 // 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. // 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, bushesChoppedCount: this.bushesChoppedCount,
rabbitsCaughtCount: this.rabbitsCaughtCount, rabbitsCaughtCount: this.rabbitsCaughtCount,
totalCoinsEarned: this.totalCoinsEarned, totalCoinsEarned: this.totalCoinsEarned,
playTimeSeconds: this.playTimeSeconds,
}); });
} }
@@ -461,6 +481,7 @@ export class GameStore {
if (this.isDead) if (this.isDead)
return; return;
this.playTimeSeconds += deltaTime;
this.updateClock(deltaTime); this.updateClock(deltaTime);
this.updateBloodMoon(deltaTime); this.updateBloodMoon(deltaTime);
this.updateMovementAndChopping(deltaTime); this.updateMovementAndChopping(deltaTime);
@@ -510,6 +531,7 @@ export class GameStore {
this.characterHitFlashTimer = HIT_FLASH_DURATION; this.characterHitFlashTimer = HIT_FLASH_DURATION;
if (this.character.hp <= 0) { if (this.character.hp <= 0) {
this.isDead = true; this.isDead = true;
this.analytics.death('killed_by_rabbit');
clearSave(); clearSave();
} }
} }
@@ -518,6 +540,7 @@ export class GameStore {
* (upgradable) day speed otherwise — rolling over into a new day, which grows fresh plants and * (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. */ * rabbits up to the new day's caps, whenever it passes midnight. */
private updateClock(deltaTime: number) { private updateClock(deltaTime: number) {
const wasNight = this.isNight;
const speed = this.isNight ? NIGHT_SPEED : this.daySpeedHoursPerSecond; const speed = this.isNight ? NIGHT_SPEED : this.daySpeedHoursPerSecond;
this.hour += speed * deltaTime; this.hour += speed * deltaTime;
while (this.hour >= 24) { while (this.hour >= 24) {
@@ -526,6 +549,16 @@ export class GameStore {
this.growNewNodesForDay(); this.growNewNodesForDay();
this.growRabbitsForDay(); 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 /** Each rabbit independently cycles: pause (deciding its next action) → jump or strike
+3
View File
@@ -29,6 +29,9 @@ export interface SaveData {
/** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes /** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes
* down when coins are spent on upgrades. */ * down when coins are spent on upgrades. */
totalCoinsEarned?: number; 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 { function isValidSave(value: unknown): value is SaveData {
+12
View File
@@ -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;
}
+9
View File
@@ -7,6 +7,15 @@
"types": ["vite/client"], "types": ["vite/client"],
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": 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 */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
+85
View File
@@ -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
View File
@@ -1,14 +1,44 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'node:path' import path from 'node:path'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite' import { defineConfig, type Plugin } from 'vite'
const rootDir = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.dirname(fileURLToPath(import.meta.url))
const engineDir = path.resolve(rootDir, '../engine') 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), restartOnVersionJsonChange()],
define: {
__APP_VERSION__: JSON.stringify(version),
},
resolve: { resolve: {
// Follow the node_modules symlink to the real sibling directory, so Vite's dev server // 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). // watches and transforms the actual engine source files (not a frozen pre-bundled copy).