diff --git a/src/App.css b/src/App.css
index 557d5bf..1ca3d50 100644
--- a/src/App.css
+++ b/src/App.css
@@ -318,6 +318,12 @@
animation: walk-bob 0.3s infinite steps(2);
}
+/* Same specificity as the walk-bob rule above (and placed after it), so the hit-shake still
+ * shows on the brief flash even if the character happens to be walking at that exact moment. */
+.entity.character.chopping svg {
+ animation: chop-shake 0.12s infinite;
+}
+
.day-night-overlay {
position: absolute;
inset: 0;
diff --git a/src/components/CharacterEntity.tsx b/src/components/CharacterEntity.tsx
index 71449d7..7954a2d 100644
--- a/src/components/CharacterEntity.tsx
+++ b/src/components/CharacterEntity.tsx
@@ -14,7 +14,7 @@ const CharacterEntity = observer(function CharacterEntity({ store }: { store: Ga
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
/>
0 ? 'chopping' : ''}`}
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
>
diff --git a/src/components/GameMap.tsx b/src/components/GameMap.tsx
index 081544a..7008291 100644
--- a/src/components/GameMap.tsx
+++ b/src/components/GameMap.tsx
@@ -20,6 +20,17 @@ interface Props {
// the app's own (rotated) layout, and have to be converted back explicitly wherever they're used.
const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)';
+/** Russian plural agreement for "день" (day) after a count. */
+function dayWord(n: number): string {
+ const mod10 = n % 10;
+ const mod100 = n % 100;
+ if (mod10 === 1 && mod100 !== 11)
+ return 'день';
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
+ return 'дня';
+ return 'дней';
+}
+
const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
return ;
});
@@ -42,8 +53,18 @@ const GameOverOverlay = observer(function GameOverOverlay({
return (
-
Кролик поймал вас ночью...
-
@@ -140,6 +161,15 @@ function GameMap({ store, onDeath }: Props) {
return;
}
+ // Nothing's under the cursor right now. Unlike a stationary tree/bush, a rabbit can jump out
+ // from under an otherwise-still cursor entirely on its own — that shouldn't read as "the
+ // player moved off the target" the way it would for a stationary node, or every autonomous
+ // jump would silently drop the attack and send the character off to walk to empty ground
+ // instead. Keep chasing the same rabbit until the player actually points at something else or
+ // releases the button.
+ if (store.heldRabbitId)
+ return;
+
const rect = e.currentTarget.getBoundingClientRect();
const { x, y } = toLocalPoint(e, rect);
store.moveTo(x, y);
diff --git a/src/components/RabbitEntity.tsx b/src/components/RabbitEntity.tsx
index 396b79c..17d181a 100644
--- a/src/components/RabbitEntity.tsx
+++ b/src/components/RabbitEntity.tsx
@@ -9,8 +9,6 @@ import PixelSprite from './PixelSprite';
const RABBIT_SIZE = 30;
const RabbitEntity = observer(function RabbitEntity({ rabbit, store }: { rabbit: RabbitState; store: GameStore }) {
- const isBeingHit = rabbit.id === store.heldRabbitId && store.isAttackingRabbit;
- const isStriking = rabbit.action === 'striking';
const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
return (
@@ -20,7 +18,7 @@ const RabbitEntity = observer(function RabbitEntity({ rabbit, store }: { rabbit:
style={{ left: rabbit.x, top: rabbit.y, width: size, height: size / 2, marginTop: size * 0.6 }}
/>
diff --git a/src/game/GameStore.ts b/src/game/GameStore.ts
index b8d075d..0b1038b 100644
--- a/src/game/GameStore.ts
+++ b/src/game/GameStore.ts
@@ -10,18 +10,19 @@ import {
ENTITY_EDGE_MARGIN,
ENTITY_MIN_DISTANCE_BETWEEN,
ENTITY_MIN_DISTANCE_FROM_CENTER,
+ HIT_FLASH_DURATION,
INTERACTION_RADIUS,
LOGS_PER_TREE,
treesForDay,
TWIGS_PER_BUSH,
TWIGS_PER_TREE,
} from './constants';
-import { BLOOD_MOON_BANNER_SECONDS, isBloodMoonNight, isNightHour, NIGHT_SPEED, nightBelongsToDay } from './daynight';
+import { BLOOD_MOON_BANNER_SECONDS, DAY_HOURS, isBloodMoonNight, isNightHour, NIGHT_SPEED, nightBelongsToDay } from './daynight';
import { clearSave, loadSave, writeSave } from './persistence';
import {
BLOOD_MOON_SIZE_MULTIPLIER,
- MEAT_VALUE,
RABBIT_ACTION_DURATION,
+ RABBIT_HEALTH_REGEN_INTERVAL,
RABBIT_HIT_RADIUS,
RABBIT_JUMP_ARC_HEIGHT,
RABBIT_JUMP_DISTANCE,
@@ -38,12 +39,24 @@ import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
/** How often (seconds of played time) the game autosaves while running. */
const AUTOSAVE_INTERVAL = 2;
-/** Hp regenerated per second while the character isn't currently being bitten. */
-const HEALTH_REGEN_PER_SECOND = 1;
-
/** A node yields its logs before its twigs. */
const RESOURCE_ORDER: NodeResourceType[] = ['log', 'twig'];
+/** Every upgrade starts at level 1 on a fresh game. */
+const DEFAULT_UPGRADE_LEVELS: Record = {
+ moveSpeed: 1,
+ chopSpeed: 1,
+ logValue: 1,
+ twigValue: 1,
+ meatValue: 1,
+ pickupRadius: 1,
+ coinSpeed: 1,
+ dayLength: 1,
+ maxHealth: 1,
+ attackDamage: 1,
+ healthRegenSpeed: 1,
+};
+
function randomId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2)}`;
}
@@ -69,6 +82,9 @@ export class GameStore {
isDead = false;
/** Current hp; starts each fresh game at characterMaxHp. */
characterHp = 5;
+ /** Seconds left to play the character's hit-shake animation — set only the instant a rabbit's
+ * strike actually lands, not for the whole time it's under threat. */
+ characterHitFlashTimer = 0;
inventory: Inventory = { logs: 0, twigs: 0, meat: 0, coins: 0 };
heldNodeId: string | null = null;
heldRabbitId: string | null = null;
@@ -77,17 +93,7 @@ export class GameStore {
moveTarget: Vec2 | null = null;
/** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */
bloodMoonBannerTimer = 0;
- upgradeLevels: Record = {
- moveSpeed: 1,
- chopSpeed: 1,
- logValue: 1,
- twigValue: 1,
- pickupRadius: 1,
- coinSpeed: 1,
- daySpeed: 1,
- maxHealth: 1,
- attackDamage: 1,
- };
+ upgradeLevels: Record = { ...DEFAULT_UPGRADE_LEVELS };
private chopProgress = 0;
private rabbitAttackProgress = 0;
@@ -166,6 +172,10 @@ export class GameStore {
return UPGRADES.twigValue.effectAtLevel(this.upgradeLevels.twigValue);
}
+ get coinsPerMeat(): number {
+ return UPGRADES.meatValue.effectAtLevel(this.upgradeLevels.meatValue);
+ }
+
get pickupRadiusPx(): number {
return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius);
}
@@ -175,17 +185,27 @@ export class GameStore {
return UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
}
- /** Game-hours per real second while it's day (morning/day/evening) — night has its own fixed speed. */
+ /** Game-hours per real second while it's day (morning/day/evening) — night has its own fixed speed.
+ * `dayLength`'s effect is the real-seconds duration of the whole day phase (see its formatEffect,
+ * "N с"), so this has to divide DAY_HOURS by it — not invert the seconds value on its own, which
+ * previously left DAY_HOURS out of the formula entirely and made every day phase take vastly
+ * longer than the "N с" the upgrade advertises. */
get daySpeedHoursPerSecond(): number {
- return UPGRADES.daySpeed.effectAtLevel(this.upgradeLevels.daySpeed);
+ const dayLengthSeconds = UPGRADES.dayLength.effectAtLevel(this.upgradeLevels.dayLength);
+ return DAY_HOURS / dayLengthSeconds;
}
get characterMaxHp(): number {
return UPGRADES.maxHealth.effectAtLevel(this.upgradeLevels.maxHealth);
}
- /** Damage per second the character deals to a rabbit it's attacking. */
- get attackDamagePerSecond(): number {
+ /** Seconds per regenerated hp for the character. */
+ get healthRegenInterval(): number {
+ return UPGRADES.healthRegenSpeed.effectAtLevel(this.upgradeLevels.healthRegenSpeed);
+ }
+
+ /** Damage dealt per swing when attacking a rabbit — see chopInterval for the swing cadence. */
+ get attackDamagePerHit(): number {
return UPGRADES.attackDamage.effectAtLevel(this.upgradeLevels.attackDamage);
}
@@ -239,6 +259,7 @@ export class GameStore {
this.coinProgress = 0;
this.saveProgress = 0;
this.isDead = false;
+ this.characterHitFlashTimer = 0;
this.bloodMoonBannerTimer = 0;
this.lastAnnouncedBloodMoonDay = -1;
@@ -268,7 +289,11 @@ export class GameStore {
// Recomputed from actionTime on the very next tick if actually mid-jump, so 0 is a safe
// starting value regardless.
z: 0,
- hp: Math.round(rabbit.hp ?? RABBIT_MAX_HP),
+ hp: rabbit.hp ?? RABBIT_MAX_HP,
+ // Purely a transient visual cue — always starts unflashed regardless of the save.
+ hitFlashTimer: 0,
+ // Transient accumulator — always starts fresh regardless of the save.
+ healthRegenProgress: 0,
action: rabbit.action ?? 'idle',
actionTime: rabbit.actionTime ?? 0,
jumpFromX: rabbit.jumpFromX ?? rabbit.x,
@@ -281,13 +306,16 @@ export class GameStore {
// Merge over defaults rather than trusting the save's shape outright, so a save from before
// meat existed still loads fine (with 0 meat) instead of leaving it undefined.
this.inventory = { logs: 0, twigs: 0, meat: 0, coins: 0, ...save?.inventory };
- if (save)
- this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels };
+ // Always reset to defaults first (not just `if (save)`) — the store instance is reused across
+ // "Новая игра" clicks, so without this a fresh game would inherit upgrade levels bought during
+ // the previous playthrough instead of starting back at level 1.
+ this.upgradeLevels = { ...DEFAULT_UPGRADE_LEVELS, ...save?.upgradeLevels };
// Restored after upgradeLevels above since characterMaxHp (the fallback for a save from
- // before hp existed, and a fresh game's starting hp) depends on them. Rounding covers a save
- // from when hp could still go fractional.
- this.characterHp = Math.round(save?.characterHp ?? this.characterMaxHp);
+ // before hp existed, and a fresh game's starting hp) depends on them. Health is allowed to be
+ // fractional (e.g. a rabbit's attack damage no longer rounds to a whole hit), so this doesn't
+ // round either.
+ this.characterHp = save?.characterHp ?? this.characterMaxHp;
}
/** Persists the full game (world state + progress) to localStorage right away. */
@@ -360,6 +388,8 @@ export class GameStore {
y: pos.y,
z: 0,
hp: RABBIT_MAX_HP,
+ hitFlashTimer: 0,
+ healthRegenProgress: 0,
action: 'idle',
// Staggered so freshly spawned rabbits don't all start their first action in lockstep.
actionTime: Math.random() * RABBIT_PAUSE_DURATION,
@@ -427,7 +457,11 @@ export class GameStore {
if (this.rabbits.some((rabbit) => rabbit.id === id)) {
this.moveTarget = null;
this.heldNodeId = null;
- this.rabbitAttackProgress = 0;
+ // Only reset progress when actually switching targets — re-affirming the same rabbit
+ // (e.g. every pointermove while the cursor stays over it) must not wipe out attack
+ // progress that's already accumulating, or a held rabbit could never take a hit.
+ if (this.heldRabbitId !== id)
+ this.rabbitAttackProgress = 0;
this.heldRabbitId = id;
}
}
@@ -483,6 +517,8 @@ export class GameStore {
this.updateMovementAndChopping(deltaTime);
this.updateRabbits(deltaTime);
this.updateHealthRegen(deltaTime);
+ if (this.characterHitFlashTimer > 0)
+ this.characterHitFlashTimer = Math.max(0, this.characterHitFlashTimer - deltaTime);
this.updateDropFlights(deltaTime);
this.updatePickups();
this.updateCoinConversion(deltaTime);
@@ -516,10 +552,13 @@ export class GameStore {
this.healthRegenProgress = 0;
return;
}
+ const interval = this.healthRegenInterval;
this.healthRegenProgress += deltaTime;
- while (this.healthRegenProgress >= 1 / HEALTH_REGEN_PER_SECOND && this.characterHp < this.characterMaxHp) {
- this.healthRegenProgress -= 1 / HEALTH_REGEN_PER_SECOND;
- this.characterHp += 1;
+ while (this.healthRegenProgress >= interval && this.characterHp < this.characterMaxHp) {
+ this.healthRegenProgress -= interval;
+ // Clamped rather than a plain += 1 — hp can now sit at a fractional value (e.g. after taking
+ // fractional damage), so a whole-point step could otherwise overshoot characterMaxHp.
+ this.characterHp = Math.min(this.characterMaxHp, this.characterHp + 1);
}
}
@@ -529,6 +568,7 @@ export class GameStore {
* to do anything else, not a wind-up the character could still escape. */
private landRabbitStrike() {
this.characterHp = Math.max(0, this.characterHp - RABBIT_STRIKE_DAMAGE);
+ this.characterHitFlashTimer = HIT_FLASH_DURATION;
if (this.characterHp <= 0) {
this.isDead = true;
clearSave();
@@ -556,11 +596,31 @@ export class GameStore {
private updateRabbits(deltaTime: number) {
if (this.rabbits.length === 0 || !this.mapSize)
return;
- for (const rabbit of this.rabbits)
+ for (const rabbit of this.rabbits) {
+ this.updateRabbitHealthRegen(rabbit, deltaTime);
this.updateRabbitAction(rabbit, deltaTime);
+ }
+ }
+
+ /** Fixed 1 hp / RABBIT_HEALTH_REGEN_INTERVAL seconds, same accumulate-then-spend cadence as the
+ * character's own regen — unlike the character's, this isn't paused by anything (no upgrade for
+ * it either), so it ticks along even mid-fight. */
+ private updateRabbitHealthRegen(rabbit: RabbitState, deltaTime: number) {
+ if (rabbit.hp >= RABBIT_MAX_HP) {
+ rabbit.healthRegenProgress = 0;
+ return;
+ }
+ rabbit.healthRegenProgress += deltaTime;
+ while (rabbit.healthRegenProgress >= RABBIT_HEALTH_REGEN_INTERVAL && rabbit.hp < RABBIT_MAX_HP) {
+ rabbit.healthRegenProgress -= RABBIT_HEALTH_REGEN_INTERVAL;
+ rabbit.hp = Math.min(RABBIT_MAX_HP, rabbit.hp + 1);
+ }
}
private updateRabbitAction(rabbit: RabbitState, deltaTime: number) {
+ if (rabbit.hitFlashTimer > 0)
+ rabbit.hitFlashTimer = Math.max(0, rabbit.hitFlashTimer - deltaTime);
+
rabbit.actionTime += deltaTime;
if (rabbit.action === 'idle') {
@@ -774,10 +834,16 @@ export class GameStore {
return;
}
- this.rabbitAttackProgress += this.attackDamagePerSecond * deltaTime;
- while (this.rabbitAttackProgress >= 1 && rabbit.hp > 0) {
- this.rabbitAttackProgress -= 1;
- rabbit.hp -= 1;
+ // Same discrete swing-cadence pattern as walkTowardNode: chopSpeed sets how often a swing
+ // lands, attackDamage sets how hard each one hits — so a swing dealing >= a rabbit's hp
+ // (e.g. 3 dmg/hit against a 3-hp rabbit) actually one-shots it, instead of that damage being
+ // smeared out continuously over a whole second.
+ const swingInterval = this.chopInterval;
+ this.rabbitAttackProgress += deltaTime;
+ while (this.rabbitAttackProgress >= swingInterval && rabbit.hp > 0) {
+ this.rabbitAttackProgress -= swingInterval;
+ rabbit.hp -= this.attackDamagePerHit;
+ rabbit.hitFlashTimer = HIT_FLASH_DURATION;
if (rabbit.hp <= 0) {
this.killRabbit(rabbit);
break;
@@ -913,7 +979,7 @@ export class GameStore {
}
if (this.inventory.meat > 0) {
this.inventory.meat -= 1;
- this.inventory.coins += MEAT_VALUE;
+ this.inventory.coins += this.coinsPerMeat;
}
}
}
diff --git a/src/game/constants.ts b/src/game/constants.ts
index af81288..df9bd8f 100644
--- a/src/game/constants.ts
+++ b/src/game/constants.ts
@@ -38,3 +38,7 @@ export const ENTITY_MIN_DISTANCE_BETWEEN = 100;
/** Nodes won't spawn within this many px of the map edge. */
export const ENTITY_EDGE_MARGIN = 70;
+
+/** How long the hit-shake animation plays once a hit actually lands (character or rabbit) —
+ * a brief flash at the moment of impact, not for however long the attack itself takes. */
+export const HIT_FLASH_DURATION = 0.2;
diff --git a/src/game/daynight.ts b/src/game/daynight.ts
index d50d715..2e271b3 100644
--- a/src/game/daynight.ts
+++ b/src/game/daynight.ts
@@ -7,8 +7,8 @@ export const NIGHT_HOURS = 24 - NIGHT_START_HOUR + NIGHT_END_HOUR;
export const DAY_HOURS = 24 - NIGHT_HOURS;
/** Fixed (non-upgradable) night time-flow speed, in game-hours per real second — derived from the
- * default 20s day-night cycle with a 10s night. */
-export const NIGHT_SPEED = NIGHT_HOURS / 10;
+ * default 10s night. */
+export const NIGHT_SPEED = NIGHT_HOURS / 20;
export function isNightHour(hour: number): boolean {
return hour >= NIGHT_START_HOUR || hour < NIGHT_END_HOUR;
diff --git a/src/game/rabbits.ts b/src/game/rabbits.ts
index 5e6d7b3..fe257e1 100644
--- a/src/game/rabbits.ts
+++ b/src/game/rabbits.ts
@@ -4,10 +4,10 @@ export function rabbitsForDay(day: number): number {
}
/** How long a jump or strike action takes to play out. */
-export const RABBIT_ACTION_DURATION = 1;
+export const RABBIT_ACTION_DURATION = 0.5;
/** How long a rabbit pauses between actions (this is also when it decides its next one). */
-export const RABBIT_PAUSE_DURATION = 1;
+export const RABBIT_PAUSE_DURATION = 0.5;
/** Max distance covered by a single jump. */
export const RABBIT_JUMP_DISTANCE = 80;
@@ -36,8 +36,9 @@ export const RABBIT_STRIKE_DAMAGE = 1;
* "roughly toward" the character rather than a perfect beeline. */
export const RABBIT_NIGHT_JUMP_SPREAD_DEGREES = 30;
-/** Coins a piece of meat is worth (fixed — unlike logs/twigs, there's no upgrade for it). */
-export const MEAT_VALUE = 5;
-
/** Rabbits (and their strike/hit ranges) are this much bigger during a blood moon. */
export const BLOOD_MOON_SIZE_MULTIPLIER = 2;
+
+/** Seconds per hp of rabbit health regen (fixed — unlike the character's own regen, there's no
+ * upgrade for it). */
+export const RABBIT_HEALTH_REGEN_INTERVAL = 3;
diff --git a/src/game/types.ts b/src/game/types.ts
index d5ffc64..945fdad 100644
--- a/src/game/types.ts
+++ b/src/game/types.ts
@@ -62,6 +62,12 @@ export interface RabbitState {
z: number;
/** Hits remaining before it dies and drops meat. */
hp: number;
+ /** Seconds left to play the hit-shake animation — set only the instant a hit actually lands,
+ * not for the whole time an attack is in progress. */
+ hitFlashTimer: number;
+ /** Accumulates real seconds toward the rabbit's next point of regenerated hp (same
+ * accumulate-then-spend pattern as the character's own health regen). */
+ healthRegenProgress: number;
action: RabbitAction;
/** Seconds elapsed within the current action (or pause, while 'idle'). */
actionTime: number;
diff --git a/src/game/upgrades.ts b/src/game/upgrades.ts
index 6c6b099..2093e7c 100644
--- a/src/game/upgrades.ts
+++ b/src/game/upgrades.ts
@@ -3,11 +3,13 @@ export type UpgradeId =
| 'chopSpeed'
| 'logValue'
| 'twigValue'
+ | 'meatValue'
| 'pickupRadius'
| 'coinSpeed'
- | 'daySpeed'
+ | 'dayLength'
| 'maxHealth'
- | 'attackDamage';
+ | 'attackDamage'
+ | 'healthRegenSpeed';
export interface UpgradeDef {
id: UpgradeId;
@@ -16,7 +18,7 @@ export interface UpgradeDef {
costGrowth: number;
/** The upgrade's stat value at a given level (levels start at 1, unbounded above). */
effectAtLevel: (level: number) => number;
- formatEffect: (value: number) => string;
+ formatEffect: (value: number, level: number) => string;
}
export const UPGRADE_LIST: UpgradeDef[] = [
@@ -52,6 +54,14 @@ export const UPGRADE_LIST: UpgradeDef[] = [
effectAtLevel: (level) => level * 0.1,
formatEffect: (v) => `${v.toFixed(2)} монет/ветка`,
},
+ {
+ id: 'meatValue',
+ name: 'Стоимость мяса',
+ baseCost: 9,
+ costGrowth: 1.4,
+ effectAtLevel: (level) => level,
+ formatEffect: (v) => `${v} монет/мясо`,
+ },
{
id: 'pickupRadius',
name: 'Радиус подбора',
@@ -68,17 +78,17 @@ export const UPGRADE_LIST: UpgradeDef[] = [
// Seconds to convert one unit of a resource into coins — every held resource type converts
// on this same cadence at once, so this is the only knob for how fast any of them drain.
effectAtLevel: (level) => 1 / Math.pow(1.15, level - 1),
- formatEffect: (v) => `${v.toFixed(2)} с/ресурс`,
+ formatEffect: (v) => `${v.toFixed(2)} с`,
},
{
- id: 'daySpeed',
- name: 'Скорость времени днём',
+ id: 'dayLength',
+ name: 'Длина дня',
baseCost: 6,
costGrowth: 1.3,
// Game-hours per real second during morning/day/evening (16 game-hours by default spread
// over the default 10s day-phase of the 20s day-night cycle) — night has its own fixed speed.
- effectAtLevel: (level) => 1.6 * Math.pow(1.15, level - 1),
- formatEffect: (v) => `${v.toFixed(2)} ч/с`,
+ effectAtLevel: (level) => 16 * Math.pow(1.15, level - 1),
+ formatEffect: (v, level) => `${v.toFixed(0)} с`,
},
{
id: 'maxHealth',
@@ -93,8 +103,21 @@ export const UPGRADE_LIST: UpgradeDef[] = [
name: 'Сила удара по кролику',
baseCost: 8,
costGrowth: 1.4,
- effectAtLevel: (level) => level,
- formatEffect: (v) => `${v} урона/с`,
+ // Damage dealt per hit — the swing cadence itself comes from chopSpeed, shared with
+ // wood-chopping, so this is purely how hard each swing lands. Health is allowed to be
+ // fractional, so this is left unrounded.
+ effectAtLevel: (level) => 0.5 * Math.pow(1.4, level - 1),
+ formatEffect: (v) => `${v.toFixed(1)} здоровья`,
+ },
+ {
+ id: 'healthRegenSpeed',
+ name: 'Скорость регена здоровья',
+ baseCost: 9,
+ costGrowth: 1.35,
+ // Seconds per regenerated hp — starts at 1 hp per 10s and shrinks (faster regen) with level,
+ // same interval-based shape as coinSpeed.
+ effectAtLevel: (level) => 10 / Math.pow(1.15, level - 1),
+ formatEffect: (v) => `${v.toFixed(1)} с/очко`,
},
];