From dbc0094986e742ea0c8fed932f435db1c0294e55 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Fri, 4 Sep 2026 21:45:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=20=D0=B7?= =?UTF-8?q?=D0=B4=D0=BE=D1=80=D0=BE=D0=B2=D1=8C=D1=8F=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=85=D0=BE=D1=82=D1=8B=20=D0=BD=D0=B0=20=D0=BA=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.css | 6 ++ src/components/CharacterEntity.tsx | 2 +- src/components/GameMap.tsx | 34 ++++++- src/components/RabbitEntity.tsx | 4 +- src/components/Sidebar.tsx | 4 +- src/components/Topbar.tsx | 2 +- src/game/GameStore.ts | 138 +++++++++++++++++++++-------- src/game/constants.ts | 4 + src/game/daynight.ts | 4 +- src/game/rabbits.ts | 11 +-- src/game/types.ts | 6 ++ src/game/upgrades.ts | 43 ++++++--- 12 files changed, 196 insertions(+), 62 deletions(-) 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 }} />
0 ? 'chopping' : ''}`} style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }} data-rabbit-id={rabbit.id} > diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index e61e84f..1cf26ce 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -31,9 +31,9 @@ function Sidebar({ store }: Props) { Ур. {level}
- {def.formatEffect(def.effectAtLevel(level))} + {def.formatEffect(def.effectAtLevel(level), level)} > - {def.formatEffect(def.effectAtLevel(level + 1))} + {def.formatEffect(def.effectAtLevel(level + 1), level + 1)}
diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index 492bc6f..7838b1b 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -18,7 +18,7 @@ function Topbar({ store, onExitToMenu }: Props) {
- {store.characterHp}/{store.characterMaxHp} + {store.characterHp.toFixed(1)}/{store.characterMaxHp}
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)} с/очко`, }, ];