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
+35 -2
View File
@@ -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
+3
View File
@@ -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 {