баланс здоровья и охоты на кроликов

This commit is contained in:
azykov@mail.ru
2026-09-04 21:45:01 +03:00
parent 396a604671
commit dbc0094986
12 changed files with 196 additions and 62 deletions
+6
View File
@@ -318,6 +318,12 @@
animation: walk-bob 0.3s infinite steps(2); 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 { .day-night-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
+1 -1
View File
@@ -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 }} style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
/> />
<div <div
className={`entity character ${store.isMoving ? 'character--walking' : ''}`} className={`entity character ${store.isMoving ? 'character--walking' : ''} ${store.characterHitFlashTimer > 0 ? 'chopping' : ''}`}
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }} style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
> >
<PixelSprite pixels={CHARACTER_SPRITE} size={CHARACTER_SIZE} /> <PixelSprite pixels={CHARACTER_SPRITE} size={CHARACTER_SIZE} />
+32 -2
View File
@@ -20,6 +20,17 @@ interface Props {
// the app's own (rotated) layout, and have to be converted back explicitly wherever they're used. // 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)'; 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 }) { const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />; return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
}); });
@@ -42,8 +53,18 @@ const GameOverOverlay = observer(function GameOverOverlay({
return ( return (
<div className="game-over-overlay"> <div className="game-over-overlay">
<div className="game-over-overlay__message">Кролик поймал вас ночью...</div> <div className="game-over-overlay__message">
<button type="button" className="menu-btn" onClick={onDeath}> Вы прожили {store.day} {dayWord(store.day)}, но вас поймали кролики
</div>
<button
type="button"
className="menu-btn"
// The whole map underneath (including this overlay) has a pointerdown handler that
// captures the pointer and drives character movement — without stopping it here, that
// handler fires first on bubble-up and swallows the tap before it becomes a click.
onPointerDown={(e) => e.stopPropagation()}
onClick={onDeath}
>
В меню В меню
</button> </button>
</div> </div>
@@ -140,6 +161,15 @@ function GameMap({ store, onDeath }: Props) {
return; 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 rect = e.currentTarget.getBoundingClientRect();
const { x, y } = toLocalPoint(e, rect); const { x, y } = toLocalPoint(e, rect);
store.moveTo(x, y); store.moveTo(x, y);
+1 -3
View File
@@ -9,8 +9,6 @@ import PixelSprite from './PixelSprite';
const RABBIT_SIZE = 30; const RABBIT_SIZE = 30;
const RabbitEntity = observer(function RabbitEntity({ rabbit, store }: { rabbit: RabbitState; store: GameStore }) { 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; const size = store.isBloodMoon ? RABBIT_SIZE * 2 : RABBIT_SIZE;
return ( 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 }} style={{ left: rabbit.x, top: rabbit.y, width: size, height: size / 2, marginTop: size * 0.6 }}
/> />
<div <div
className={`entity rabbit ${isBeingHit || isStriking ? 'chopping' : ''}`} className={`entity rabbit ${rabbit.hitFlashTimer > 0 ? 'chopping' : ''}`}
style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }} style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }}
data-rabbit-id={rabbit.id} data-rabbit-id={rabbit.id}
> >
+2 -2
View File
@@ -31,9 +31,9 @@ function Sidebar({ store }: Props) {
<span className="upgrade-btn__level">Ур. {level}</span> <span className="upgrade-btn__level">Ур. {level}</span>
</div> </div>
<div className="upgrade-btn__effects"> <div className="upgrade-btn__effects">
<span>{def.formatEffect(def.effectAtLevel(level))}</span> <span>{def.formatEffect(def.effectAtLevel(level), level)}</span>
<span className="upgrade-btn__arrow">&gt;</span> <span className="upgrade-btn__arrow">&gt;</span>
<span>{def.formatEffect(def.effectAtLevel(level + 1))}</span> <span>{def.formatEffect(def.effectAtLevel(level + 1), level + 1)}</span>
</div> </div>
<div className="upgrade-btn__cost"> <div className="upgrade-btn__cost">
<PixelSprite pixels={COIN_SPRITE} size={16} /> <PixelSprite pixels={COIN_SPRITE} size={16} />
+1 -1
View File
@@ -18,7 +18,7 @@ function Topbar({ store, onExitToMenu }: Props) {
</div> </div>
<div className="inventory-slot"> <div className="inventory-slot">
<PixelSprite pixels={HEART_SPRITE} size={18} /> <PixelSprite pixels={HEART_SPRITE} size={18} />
<span>{store.characterHp}/{store.characterMaxHp}</span> <span>{store.characterHp.toFixed(1)}/{store.characterMaxHp}</span>
</div> </div>
<div className="spacer" /> <div className="spacer" />
<div className="inventory-slot"> <div className="inventory-slot">
+102 -36
View File
@@ -10,18 +10,19 @@ import {
ENTITY_EDGE_MARGIN, ENTITY_EDGE_MARGIN,
ENTITY_MIN_DISTANCE_BETWEEN, ENTITY_MIN_DISTANCE_BETWEEN,
ENTITY_MIN_DISTANCE_FROM_CENTER, ENTITY_MIN_DISTANCE_FROM_CENTER,
HIT_FLASH_DURATION,
INTERACTION_RADIUS, INTERACTION_RADIUS,
LOGS_PER_TREE, LOGS_PER_TREE,
treesForDay, treesForDay,
TWIGS_PER_BUSH, TWIGS_PER_BUSH,
TWIGS_PER_TREE, TWIGS_PER_TREE,
} from './constants'; } 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 { clearSave, loadSave, writeSave } from './persistence';
import { import {
BLOOD_MOON_SIZE_MULTIPLIER, BLOOD_MOON_SIZE_MULTIPLIER,
MEAT_VALUE,
RABBIT_ACTION_DURATION, RABBIT_ACTION_DURATION,
RABBIT_HEALTH_REGEN_INTERVAL,
RABBIT_HIT_RADIUS, RABBIT_HIT_RADIUS,
RABBIT_JUMP_ARC_HEIGHT, RABBIT_JUMP_ARC_HEIGHT,
RABBIT_JUMP_DISTANCE, 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. */ /** How often (seconds of played time) the game autosaves while running. */
const AUTOSAVE_INTERVAL = 2; 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. */ /** A node yields its logs before its twigs. */
const RESOURCE_ORDER: NodeResourceType[] = ['log', 'twig']; const RESOURCE_ORDER: NodeResourceType[] = ['log', 'twig'];
/** Every upgrade starts at level 1 on a fresh game. */
const DEFAULT_UPGRADE_LEVELS: Record<UpgradeId, number> = {
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 { function randomId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2)}`; return `${prefix}-${Math.random().toString(36).slice(2)}`;
} }
@@ -69,6 +82,9 @@ export class GameStore {
isDead = false; isDead = false;
/** Current hp; starts each fresh game at characterMaxHp. */ /** Current hp; starts each fresh game at characterMaxHp. */
characterHp = 5; 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 }; inventory: Inventory = { logs: 0, twigs: 0, meat: 0, coins: 0 };
heldNodeId: string | null = null; heldNodeId: string | null = null;
heldRabbitId: string | null = null; heldRabbitId: string | null = null;
@@ -77,17 +93,7 @@ export class GameStore {
moveTarget: Vec2 | null = null; moveTarget: Vec2 | null = null;
/** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */ /** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */
bloodMoonBannerTimer = 0; bloodMoonBannerTimer = 0;
upgradeLevels: Record<UpgradeId, number> = { upgradeLevels: Record<UpgradeId, number> = { ...DEFAULT_UPGRADE_LEVELS };
moveSpeed: 1,
chopSpeed: 1,
logValue: 1,
twigValue: 1,
pickupRadius: 1,
coinSpeed: 1,
daySpeed: 1,
maxHealth: 1,
attackDamage: 1,
};
private chopProgress = 0; private chopProgress = 0;
private rabbitAttackProgress = 0; private rabbitAttackProgress = 0;
@@ -166,6 +172,10 @@ export class GameStore {
return UPGRADES.twigValue.effectAtLevel(this.upgradeLevels.twigValue); return UPGRADES.twigValue.effectAtLevel(this.upgradeLevels.twigValue);
} }
get coinsPerMeat(): number {
return UPGRADES.meatValue.effectAtLevel(this.upgradeLevels.meatValue);
}
get pickupRadiusPx(): number { get pickupRadiusPx(): number {
return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius); return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius);
} }
@@ -175,17 +185,27 @@ export class GameStore {
return UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed); 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 { 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 { get characterMaxHp(): number {
return UPGRADES.maxHealth.effectAtLevel(this.upgradeLevels.maxHealth); return UPGRADES.maxHealth.effectAtLevel(this.upgradeLevels.maxHealth);
} }
/** Damage per second the character deals to a rabbit it's attacking. */ /** Seconds per regenerated hp for the character. */
get attackDamagePerSecond(): number { 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); return UPGRADES.attackDamage.effectAtLevel(this.upgradeLevels.attackDamage);
} }
@@ -239,6 +259,7 @@ export class GameStore {
this.coinProgress = 0; this.coinProgress = 0;
this.saveProgress = 0; this.saveProgress = 0;
this.isDead = false; this.isDead = false;
this.characterHitFlashTimer = 0;
this.bloodMoonBannerTimer = 0; this.bloodMoonBannerTimer = 0;
this.lastAnnouncedBloodMoonDay = -1; 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 // Recomputed from actionTime on the very next tick if actually mid-jump, so 0 is a safe
// starting value regardless. // starting value regardless.
z: 0, 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', action: rabbit.action ?? 'idle',
actionTime: rabbit.actionTime ?? 0, actionTime: rabbit.actionTime ?? 0,
jumpFromX: rabbit.jumpFromX ?? rabbit.x, 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 // 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. // 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 }; this.inventory = { logs: 0, twigs: 0, meat: 0, coins: 0, ...save?.inventory };
if (save) // Always reset to defaults first (not just `if (save)`) — the store instance is reused across
this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels }; // "Новая игра" 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 // 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 // before hp existed, and a fresh game's starting hp) depends on them. Health is allowed to be
// from when hp could still go fractional. // fractional (e.g. a rabbit's attack damage no longer rounds to a whole hit), so this doesn't
this.characterHp = Math.round(save?.characterHp ?? this.characterMaxHp); // round either.
this.characterHp = save?.characterHp ?? this.characterMaxHp;
} }
/** Persists the full game (world state + progress) to localStorage right away. */ /** Persists the full game (world state + progress) to localStorage right away. */
@@ -360,6 +388,8 @@ export class GameStore {
y: pos.y, y: pos.y,
z: 0, z: 0,
hp: RABBIT_MAX_HP, hp: RABBIT_MAX_HP,
hitFlashTimer: 0,
healthRegenProgress: 0,
action: 'idle', action: 'idle',
// Staggered so freshly spawned rabbits don't all start their first action in lockstep. // Staggered so freshly spawned rabbits don't all start their first action in lockstep.
actionTime: Math.random() * RABBIT_PAUSE_DURATION, actionTime: Math.random() * RABBIT_PAUSE_DURATION,
@@ -427,7 +457,11 @@ export class GameStore {
if (this.rabbits.some((rabbit) => rabbit.id === id)) { if (this.rabbits.some((rabbit) => rabbit.id === id)) {
this.moveTarget = null; this.moveTarget = null;
this.heldNodeId = 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; this.heldRabbitId = id;
} }
} }
@@ -483,6 +517,8 @@ export class GameStore {
this.updateMovementAndChopping(deltaTime); this.updateMovementAndChopping(deltaTime);
this.updateRabbits(deltaTime); this.updateRabbits(deltaTime);
this.updateHealthRegen(deltaTime); this.updateHealthRegen(deltaTime);
if (this.characterHitFlashTimer > 0)
this.characterHitFlashTimer = Math.max(0, this.characterHitFlashTimer - deltaTime);
this.updateDropFlights(deltaTime); this.updateDropFlights(deltaTime);
this.updatePickups(); this.updatePickups();
this.updateCoinConversion(deltaTime); this.updateCoinConversion(deltaTime);
@@ -516,10 +552,13 @@ export class GameStore {
this.healthRegenProgress = 0; this.healthRegenProgress = 0;
return; return;
} }
const interval = this.healthRegenInterval;
this.healthRegenProgress += deltaTime; this.healthRegenProgress += deltaTime;
while (this.healthRegenProgress >= 1 / HEALTH_REGEN_PER_SECOND && this.characterHp < this.characterMaxHp) { while (this.healthRegenProgress >= interval && this.characterHp < this.characterMaxHp) {
this.healthRegenProgress -= 1 / HEALTH_REGEN_PER_SECOND; this.healthRegenProgress -= interval;
this.characterHp += 1; // 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. */ * to do anything else, not a wind-up the character could still escape. */
private landRabbitStrike() { private landRabbitStrike() {
this.characterHp = Math.max(0, this.characterHp - RABBIT_STRIKE_DAMAGE); this.characterHp = Math.max(0, this.characterHp - RABBIT_STRIKE_DAMAGE);
this.characterHitFlashTimer = HIT_FLASH_DURATION;
if (this.characterHp <= 0) { if (this.characterHp <= 0) {
this.isDead = true; this.isDead = true;
clearSave(); clearSave();
@@ -556,11 +596,31 @@ export class GameStore {
private updateRabbits(deltaTime: number) { private updateRabbits(deltaTime: number) {
if (this.rabbits.length === 0 || !this.mapSize) if (this.rabbits.length === 0 || !this.mapSize)
return; return;
for (const rabbit of this.rabbits) for (const rabbit of this.rabbits) {
this.updateRabbitHealthRegen(rabbit, deltaTime);
this.updateRabbitAction(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) { private updateRabbitAction(rabbit: RabbitState, deltaTime: number) {
if (rabbit.hitFlashTimer > 0)
rabbit.hitFlashTimer = Math.max(0, rabbit.hitFlashTimer - deltaTime);
rabbit.actionTime += deltaTime; rabbit.actionTime += deltaTime;
if (rabbit.action === 'idle') { if (rabbit.action === 'idle') {
@@ -774,10 +834,16 @@ export class GameStore {
return; return;
} }
this.rabbitAttackProgress += this.attackDamagePerSecond * deltaTime; // Same discrete swing-cadence pattern as walkTowardNode: chopSpeed sets how often a swing
while (this.rabbitAttackProgress >= 1 && rabbit.hp > 0) { // lands, attackDamage sets how hard each one hits — so a swing dealing >= a rabbit's hp
this.rabbitAttackProgress -= 1; // (e.g. 3 dmg/hit against a 3-hp rabbit) actually one-shots it, instead of that damage being
rabbit.hp -= 1; // 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) { if (rabbit.hp <= 0) {
this.killRabbit(rabbit); this.killRabbit(rabbit);
break; break;
@@ -913,7 +979,7 @@ export class GameStore {
} }
if (this.inventory.meat > 0) { if (this.inventory.meat > 0) {
this.inventory.meat -= 1; this.inventory.meat -= 1;
this.inventory.coins += MEAT_VALUE; this.inventory.coins += this.coinsPerMeat;
} }
} }
} }
+4
View File
@@ -38,3 +38,7 @@ export const ENTITY_MIN_DISTANCE_BETWEEN = 100;
/** Nodes won't spawn within this many px of the map edge. */ /** Nodes won't spawn within this many px of the map edge. */
export const ENTITY_EDGE_MARGIN = 70; 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;
+2 -2
View File
@@ -7,8 +7,8 @@ export const NIGHT_HOURS = 24 - NIGHT_START_HOUR + NIGHT_END_HOUR;
export const DAY_HOURS = 24 - NIGHT_HOURS; export const DAY_HOURS = 24 - NIGHT_HOURS;
/** Fixed (non-upgradable) night time-flow speed, in game-hours per real second — derived from the /** 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. */ * default 10s night. */
export const NIGHT_SPEED = NIGHT_HOURS / 10; export const NIGHT_SPEED = NIGHT_HOURS / 20;
export function isNightHour(hour: number): boolean { export function isNightHour(hour: number): boolean {
return hour >= NIGHT_START_HOUR || hour < NIGHT_END_HOUR; return hour >= NIGHT_START_HOUR || hour < NIGHT_END_HOUR;
+6 -5
View File
@@ -4,10 +4,10 @@ export function rabbitsForDay(day: number): number {
} }
/** How long a jump or strike action takes to play out. */ /** 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). */ /** 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. */ /** Max distance covered by a single jump. */
export const RABBIT_JUMP_DISTANCE = 80; 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. */ * "roughly toward" the character rather than a perfect beeline. */
export const RABBIT_NIGHT_JUMP_SPREAD_DEGREES = 30; 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. */ /** Rabbits (and their strike/hit ranges) are this much bigger during a blood moon. */
export const BLOOD_MOON_SIZE_MULTIPLIER = 2; 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;
+6
View File
@@ -62,6 +62,12 @@ export interface RabbitState {
z: number; z: number;
/** Hits remaining before it dies and drops meat. */ /** Hits remaining before it dies and drops meat. */
hp: number; 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; action: RabbitAction;
/** Seconds elapsed within the current action (or pause, while 'idle'). */ /** Seconds elapsed within the current action (or pause, while 'idle'). */
actionTime: number; actionTime: number;
+33 -10
View File
@@ -3,11 +3,13 @@ export type UpgradeId =
| 'chopSpeed' | 'chopSpeed'
| 'logValue' | 'logValue'
| 'twigValue' | 'twigValue'
| 'meatValue'
| 'pickupRadius' | 'pickupRadius'
| 'coinSpeed' | 'coinSpeed'
| 'daySpeed' | 'dayLength'
| 'maxHealth' | 'maxHealth'
| 'attackDamage'; | 'attackDamage'
| 'healthRegenSpeed';
export interface UpgradeDef { export interface UpgradeDef {
id: UpgradeId; id: UpgradeId;
@@ -16,7 +18,7 @@ export interface UpgradeDef {
costGrowth: number; costGrowth: number;
/** The upgrade's stat value at a given level (levels start at 1, unbounded above). */ /** The upgrade's stat value at a given level (levels start at 1, unbounded above). */
effectAtLevel: (level: number) => number; effectAtLevel: (level: number) => number;
formatEffect: (value: number) => string; formatEffect: (value: number, level: number) => string;
} }
export const UPGRADE_LIST: UpgradeDef[] = [ export const UPGRADE_LIST: UpgradeDef[] = [
@@ -52,6 +54,14 @@ export const UPGRADE_LIST: UpgradeDef[] = [
effectAtLevel: (level) => level * 0.1, effectAtLevel: (level) => level * 0.1,
formatEffect: (v) => `${v.toFixed(2)} монет/ветка`, formatEffect: (v) => `${v.toFixed(2)} монет/ветка`,
}, },
{
id: 'meatValue',
name: 'Стоимость мяса',
baseCost: 9,
costGrowth: 1.4,
effectAtLevel: (level) => level,
formatEffect: (v) => `${v} монет/мясо`,
},
{ {
id: 'pickupRadius', id: 'pickupRadius',
name: 'Радиус подбора', 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 // 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. // 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), effectAtLevel: (level) => 1 / Math.pow(1.15, level - 1),
formatEffect: (v) => `${v.toFixed(2)} с/ресурс`, formatEffect: (v) => `${v.toFixed(2)} с`,
}, },
{ {
id: 'daySpeed', id: 'dayLength',
name: 'Скорость времени днём', name: 'Длина дня',
baseCost: 6, baseCost: 6,
costGrowth: 1.3, costGrowth: 1.3,
// Game-hours per real second during morning/day/evening (16 game-hours by default spread // 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. // 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), effectAtLevel: (level) => 16 * Math.pow(1.15, level - 1),
formatEffect: (v) => `${v.toFixed(2)} ч/с`, formatEffect: (v, level) => `${v.toFixed(0)} с`,
}, },
{ {
id: 'maxHealth', id: 'maxHealth',
@@ -93,8 +103,21 @@ export const UPGRADE_LIST: UpgradeDef[] = [
name: 'Сила удара по кролику', name: 'Сила удара по кролику',
baseCost: 8, baseCost: 8,
costGrowth: 1.4, costGrowth: 1.4,
effectAtLevel: (level) => level, // Damage dealt per hit — the swing cadence itself comes from chopSpeed, shared with
formatEffect: (v) => `${v} урона/с`, // 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)} с/очко`,
}, },
]; ];