баланс здоровья и охоты на кроликов
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
<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) }}
|
||||
>
|
||||
<PixelSprite pixels={CHARACTER_SPRITE} size={CHARACTER_SIZE} />
|
||||
|
||||
@@ -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 <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
|
||||
});
|
||||
@@ -42,8 +53,18 @@ const GameOverOverlay = observer(function GameOverOverlay({
|
||||
|
||||
return (
|
||||
<div className="game-over-overlay">
|
||||
<div className="game-over-overlay__message">Кролик поймал вас ночью...</div>
|
||||
<button type="button" className="menu-btn" onClick={onDeath}>
|
||||
<div className="game-over-overlay__message">
|
||||
Вы прожили {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>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
<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) }}
|
||||
data-rabbit-id={rabbit.id}
|
||||
>
|
||||
|
||||
@@ -31,9 +31,9 @@ function Sidebar({ store }: Props) {
|
||||
<span className="upgrade-btn__level">Ур. {level}</span>
|
||||
</div>
|
||||
<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">></span>
|
||||
<span>{def.formatEffect(def.effectAtLevel(level + 1))}</span>
|
||||
<span>{def.formatEffect(def.effectAtLevel(level + 1), level + 1)}</span>
|
||||
</div>
|
||||
<div className="upgrade-btn__cost">
|
||||
<PixelSprite pixels={COIN_SPRITE} size={16} />
|
||||
|
||||
@@ -18,7 +18,7 @@ function Topbar({ store, onExitToMenu }: Props) {
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={HEART_SPRITE} size={18} />
|
||||
<span>{store.characterHp}/{store.characterMaxHp}</span>
|
||||
<span>{store.characterHp.toFixed(1)}/{store.characterMaxHp}</span>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<div className="inventory-slot">
|
||||
|
||||
+101
-35
@@ -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<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 {
|
||||
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<UpgradeId, number> = {
|
||||
moveSpeed: 1,
|
||||
chopSpeed: 1,
|
||||
logValue: 1,
|
||||
twigValue: 1,
|
||||
pickupRadius: 1,
|
||||
coinSpeed: 1,
|
||||
daySpeed: 1,
|
||||
maxHealth: 1,
|
||||
attackDamage: 1,
|
||||
};
|
||||
upgradeLevels: Record<UpgradeId, number> = { ...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,6 +457,10 @@ export class GameStore {
|
||||
if (this.rabbits.some((rabbit) => rabbit.id === id)) {
|
||||
this.moveTarget = null;
|
||||
this.heldNodeId = null;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-5
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+33
-10
@@ -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)} с/очко`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user