цикл день-ночь, кролики, кровавая луна

This commit is contained in:
azykov@mail.ru
2026-09-04 20:31:53 +03:00
parent 982bbce813
commit 396a604671
17 changed files with 1061 additions and 171 deletions
+86 -25
View File
@@ -199,6 +199,23 @@
user-select: none;
}
.health-bar {
position: absolute;
width: 28px;
height: 5px;
transform: translate(-50%, -100%);
background: rgba(0, 0, 0, 0.5);
border: 1px solid #1b1611;
z-index: 500;
pointer-events: none;
}
.health-bar__fill {
height: 100%;
background: #4caf50;
transition: none;
}
.entity {
position: absolute;
transform: translate(-50%, -50%);
@@ -250,6 +267,24 @@
z-index: 0;
}
.entity.rabbit-shadow {
width: 18px;
height: 9px;
border-radius: 50%;
margin: 32px 10px 0px 0px;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.entity.meat-shadow {
width: 18px;
height: 9px;
border-radius: 50%;
margin: 7px 0px 0px 0px;
background: rgba(0, 0, 0, 0.35);
z-index: 0;
}
.entity.tree {
pointer-events: auto;
cursor: pointer;
@@ -264,6 +299,13 @@
margin: -8px;
}
.entity.rabbit {
pointer-events: auto;
cursor: pointer;
padding: 6px;
margin: -6px;
}
.entity.chopping svg {
animation: chop-shake 0.12s infinite;
}
@@ -276,37 +318,56 @@
animation: walk-bob 0.3s infinite steps(2);
}
.next-level-btn {
.day-night-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 9999;
}
.blood-moon-banner {
position: absolute;
top: 15%;
left: 50%;
top: 40%;
transform: translate(-50%, -50%);
padding: 16px 24px;
background: var(--panel-bg);
border: 4px solid var(--accent);
color: var(--accent);
font-family: inherit;
font-size: 14px;
text-shadow: 2px 2px 0 var(--panel-border);
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.35);
cursor: pointer;
animation: next-level-pop 0.6s ease-out;
transform: translateX(-50%);
pointer-events: none;
z-index: 9999;
font-size: 24px;
color: #e63946;
text-shadow: 3px 3px 0 #2a0505;
animation: blood-moon-pulse 1s ease-in-out infinite;
white-space: nowrap;
}
.next-level-btn:hover {
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.35);
margin: -4px 0px 0px -4px;
}
@keyframes next-level-pop {
0% {
transform: translate(-50%, -50%) scale(0.85);
opacity: 0;
}
100% {
transform: translate(-50%, -50%) scale(1);
@keyframes blood-moon-pulse {
0%, 100% {
opacity: 1;
transform: translateX(-50%) scale(1);
}
50% {
opacity: 0.75;
transform: translateX(-50%) scale(1.08);
}
}
.game-over-overlay {
position: absolute;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.75);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
}
.game-over-overlay__message {
font-size: 16px;
color: var(--accent);
text-shadow: 2px 2px 0 var(--panel-border);
text-align: center;
padding: 0 20px;
}
@keyframes chop-shake {
+7 -1
View File
@@ -25,6 +25,12 @@ function App() {
setScreen('menu');
}
// Called after a death overlay is acknowledged: the store already wiped the save itself the
// moment the character was caught, so this just returns to the menu without saving over it.
function handleDeath() {
setScreen('menu');
}
if (screen === 'menu') {
return <MainMenu onNewGame={handleNewGame} onContinue={() => setScreen('game')} />;
}
@@ -34,7 +40,7 @@ function App() {
<Topbar store={store} onExitToMenu={handleExitToMenu} />
<div className="main-row">
<Sidebar store={store} />
<GameMap store={store} />
<GameMap store={store} onDeath={handleDeath} />
</div>
</div>
);
+33
View File
@@ -0,0 +1,33 @@
import { observer } from 'mobx-react-lite';
import type { GameStore } from '../game/GameStore';
import { CHARACTER_SPRITE } from '../game/sprites';
import HealthBar from './HealthBar';
import PixelSprite from './PixelSprite';
const CHARACTER_SIZE = 48;
const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) {
return (
<>
<div
className="entity character-shadow"
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
/>
<div
className={`entity character ${store.isMoving ? 'character--walking' : ''}`}
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
>
<PixelSprite pixels={CHARACTER_SPRITE} size={CHARACTER_SIZE} />
</div>
{store.characterHp < store.characterMaxHp && (
<HealthBar
x={store.character.x}
y={store.character.y - CHARACTER_SIZE * 0.5 - 6}
ratio={store.characterHp / store.characterMaxHp}
/>
)}
</>
);
});
export default CharacterEntity;
+57 -74
View File
@@ -2,12 +2,15 @@ import { observer } from 'mobx-react-lite';
import { useEffect, useRef, useState } from 'react';
import type { PointerEvent } from 'react';
import type { GameStore } from '../game/GameStore';
import type { ResourceDrop, ResourceNode } from '../game/types';
import { BUSH_SPRITE, CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE, TWIG_SPRITE } from '../game/sprites';
import PixelSprite from './PixelSprite';
import { overlayColorForHour } from '../game/daynight';
import CharacterEntity from './CharacterEntity';
import NodeEntity from './NodeEntity';
import RabbitEntity from './RabbitEntity';
import ResourceDropEntity from './ResourceDropEntity';
interface Props {
store: GameStore;
onDeath: () => void;
}
// Must mirror the @media rule in index.css that rotates #root into landscape on a portrait
@@ -17,65 +20,37 @@ 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)';
// Each entity below is its own observer so mobx can subscribe it only to the fields it actually
// reads. Otherwise, with everything read inside GameMap's single observer, the character moving
// every animation frame would re-render the whole node/drop list too, not just itself.
const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
});
const DROP_SPRITES = { log: LOG_SPRITE, twig: TWIG_SPRITE };
const DROP_SIZES = { log: 20, twig: 18 };
const BloodMoonBanner = observer(function BloodMoonBanner({ store }: { store: GameStore }) {
if (!store.bloodMoonBannerVisible)
return null;
return <div className="blood-moon-banner">КРОВАВАЯ ЛУНА!</div>;
});
const GameOverOverlay = observer(function GameOverOverlay({
store,
onDeath,
}: {
store: GameStore;
onDeath: () => void;
}) {
if (!store.isDead)
return null;
const ResourceDropEntity = observer(function ResourceDropEntity({ drop }: { drop: ResourceDrop }) {
return (
<div>
<div className={`entity ${drop.resource}-shadow`} style={{ left: drop.x, top: drop.y }} />
<div
className={`entity ${drop.resource}`}
style={{ left: drop.x, top: drop.y - drop.z, zIndex: Math.floor(drop.y - drop.z) }}
>
<PixelSprite pixels={DROP_SPRITES[drop.resource]} size={DROP_SIZES[drop.resource]} />
</div>
<div className="game-over-overlay">
<div className="game-over-overlay__message">Кролик поймал вас ночью...</div>
<button type="button" className="menu-btn" onClick={onDeath}>
В меню
</button>
</div>
);
});
const NODE_SPRITES = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
const NODE_SIZES = { tree: 64, bush: 40 };
const NodeEntity = observer(function NodeEntity({ node, store }: { node: ResourceNode; store: GameStore }) {
const isChopping = node.id === store.heldNodeId && store.isChopping;
return (
<div>
<div className={`entity ${node.kind}-shadow`} style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) - 1 }} />
<div
className={`entity ${node.kind} ${isChopping ? 'chopping' : ''}`}
style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) }}
data-node-id={node.id}
>
<PixelSprite pixels={NODE_SPRITES[node.kind]} size={NODE_SIZES[node.kind]} />
</div>
</div>
);
});
const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) {
return (
<>
<div
className="entity character-shadow"
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
/>
<div
className={`entity character ${store.isMoving ? 'character--walking' : ''}`}
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
>
<PixelSprite pixels={CHARACTER_SPRITE} size={48} />
</div>
</>
);
});
function GameMap({ store }: Props) {
function GameMap({ store, onDeath }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [ready, setReady] = useState(false);
const isRotatedRef = useRef(false);
@@ -120,12 +95,18 @@ function GameMap({ store }: Props) {
};
}, [store]);
/** Finds the node (if any) the pointer is currently over, by DOM hit-testing at its client
* coordinates. elementFromPoint uses physical screen coordinates and already accounts for any
* CSS transform, so this needs no rotation correction (unlike toLocalPoint below). */
function nodeIdAtPoint(clientX: number, clientY: number): string | null {
const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-node-id]');
return hit?.getAttribute('data-node-id') ?? null;
/** Finds the node or rabbit (if any) the pointer is currently over, by DOM hit-testing at its
* client coordinates. elementFromPoint uses physical screen coordinates and already accounts
* for any CSS transform, so this needs no rotation correction (unlike toLocalPoint below). */
function targetAtPoint(clientX: number, clientY: number): { kind: 'node' | 'rabbit'; id: string } | null {
const el = document.elementFromPoint(clientX, clientY);
const nodeHit = el?.closest('[data-node-id]');
if (nodeHit)
return { kind: 'node', id: nodeHit.getAttribute('data-node-id')! };
const rabbitHit = el?.closest('[data-rabbit-id]');
if (rabbitHit)
return { kind: 'rabbit', id: rabbitHit.getAttribute('data-rabbit-id')! };
return null;
}
/** Converts a pointer event's physical screen position into the map's own logical (x, y) —
@@ -150,9 +131,12 @@ function GameMap({ store }: Props) {
return;
}
const nodeId = nodeIdAtPoint(e.clientX, e.clientY);
if (nodeId) {
store.holdNode(nodeId);
const target = targetAtPoint(e.clientX, e.clientY);
if (target) {
if (target.kind === 'node')
store.holdNode(target.id);
else
store.holdRabbit(target.id);
return;
}
@@ -185,18 +169,17 @@ function GameMap({ store }: Props) {
<NodeEntity key={node.id} node={node} store={store} />
))}
{store.rabbits.map((rabbit) => (
<RabbitEntity key={rabbit.id} rabbit={rabbit} store={store} />
))}
<CharacterEntity store={store} />
{store.canAdvanceLevel && (
<button
type="button"
className="next-level-btn"
onPointerDown={(e) => e.stopPropagation()}
onClick={store.nextLevel}
>
Перейти на следующий уровень
</button>
)}
<DayNightOverlay store={store} />
<BloodMoonBanner store={store} />
<GameOverOverlay store={store} onDeath={onDeath} />
</>
)}
</div>
+10
View File
@@ -0,0 +1,10 @@
/** A small floating bar above an entity's sprite, anchored bottom-center at (x, yAboveSprite). */
function HealthBar({ x, y, ratio }: { x: number; y: number; ratio: number }) {
return (
<div className="health-bar" style={{ left: x, top: y }}>
<div className="health-bar__fill" style={{ width: `${Math.max(0, Math.min(1, ratio)) * 100}%` }} />
</div>
);
}
export default HealthBar;
+27
View File
@@ -0,0 +1,27 @@
import { observer } from 'mobx-react-lite';
import type { GameStore } from '../game/GameStore';
import type { ResourceNode } from '../game/types';
import { BUSH_SPRITE, TREE_SPRITE } from '../game/sprites';
import PixelSprite from './PixelSprite';
const NODE_SPRITES = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
const NODE_SIZES = { tree: 64, bush: 40 };
const NodeEntity = observer(function NodeEntity({ node, store }: { node: ResourceNode; store: GameStore }) {
const isChopping = node.id === store.heldNodeId && store.isChopping;
return (
<div>
<div className={`entity ${node.kind}-shadow`} style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) - 1 }} />
<div
className={`entity ${node.kind} ${isChopping ? 'chopping' : ''}`}
style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) }}
data-node-id={node.id}
>
<PixelSprite pixels={NODE_SPRITES[node.kind]} size={NODE_SIZES[node.kind]} />
</div>
</div>
);
});
export default NodeEntity;
+36
View File
@@ -0,0 +1,36 @@
import { observer } from 'mobx-react-lite';
import type { GameStore } from '../game/GameStore';
import type { RabbitState } from '../game/types';
import { RABBIT_MAX_HP } from '../game/rabbits';
import { RABBIT_NIGHT_SPRITE, RABBIT_SPRITE } from '../game/sprites';
import HealthBar from './HealthBar';
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 (
<div>
<div
className="entity rabbit-shadow"
style={{ left: rabbit.x, top: rabbit.y, width: size, height: size / 2, marginTop: size * 0.6 }}
/>
<div
className={`entity rabbit ${isBeingHit || isStriking ? 'chopping' : ''}`}
style={{ left: rabbit.x, top: rabbit.y - rabbit.z, zIndex: Math.floor(rabbit.y - rabbit.z) }}
data-rabbit-id={rabbit.id}
>
<PixelSprite pixels={store.isNight ? RABBIT_NIGHT_SPRITE : RABBIT_SPRITE} size={size} />
</div>
{rabbit.hp < RABBIT_MAX_HP && (
<HealthBar x={rabbit.x} y={rabbit.y - rabbit.z - size * 0.5 - 6} ratio={rabbit.hp / RABBIT_MAX_HP} />
)}
</div>
);
});
export default RabbitEntity;
+23
View File
@@ -0,0 +1,23 @@
import { observer } from 'mobx-react-lite';
import type { ResourceDrop } from '../game/types';
import { LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
import PixelSprite from './PixelSprite';
const DROP_SPRITES = { log: LOG_SPRITE, twig: TWIG_SPRITE, meat: MEAT_SPRITE };
const DROP_SIZES = { log: 20, twig: 18, meat: 18 };
const ResourceDropEntity = observer(function ResourceDropEntity({ drop }: { drop: ResourceDrop }) {
return (
<div>
<div className={`entity ${drop.resource}-shadow`} style={{ left: drop.x, top: drop.y }} />
<div
className={`entity ${drop.resource}`}
style={{ left: drop.x, top: drop.y - drop.z, zIndex: Math.floor(drop.y - drop.z) }}
>
<PixelSprite pixels={DROP_SPRITES[drop.resource]} size={DROP_SIZES[drop.resource]} />
</div>
</div>
);
});
export default ResourceDropEntity;
+11 -2
View File
@@ -1,6 +1,7 @@
import { observer } from 'mobx-react-lite';
import { formatClock } from '../game/daynight';
import type { GameStore } from '../game/GameStore';
import { COIN_SPRITE, LOG_SPRITE, TWIG_SPRITE } from '../game/sprites';
import { COIN_SPRITE, HEART_SPRITE, LOG_SPRITE, MEAT_SPRITE, TWIG_SPRITE } from '../game/sprites';
import PixelSprite from './PixelSprite';
interface Props {
@@ -13,7 +14,11 @@ function Topbar({ store, onExitToMenu }: Props) {
<div className="topbar">
<h1 className="topbar__title">Forest</h1>
<div className="inventory-slot">
<span>Уровень {store.level}</span>
<span>День {store.day}, {formatClock(store.hour)}</span>
</div>
<div className="inventory-slot">
<PixelSprite pixels={HEART_SPRITE} size={18} />
<span>{store.characterHp}/{store.characterMaxHp}</span>
</div>
<div className="spacer" />
<div className="inventory-slot">
@@ -24,6 +29,10 @@ function Topbar({ store, onExitToMenu }: Props) {
<PixelSprite pixels={LOG_SPRITE} size={28} />
<span>{store.inventory.logs}</span>
</div>
<div className="inventory-slot">
<PixelSprite pixels={MEAT_SPRITE} size={22} />
<span>{store.inventory.meat}</span>
</div>
<div className="inventory-slot">
<PixelSprite pixels={COIN_SPRITE} size={24} />
<span>{store.inventory.coins.toFixed(1)}</span>
+477 -55
View File
@@ -1,7 +1,7 @@
import { makeAutoObservable } from 'mobx';
import type { Inventory, ResourceDrop, ResourceNode, ResourceType, Vec2 } from './types';
import type { Inventory, NodeResourceType, RabbitState, ResourceDrop, ResourceNode, ResourceType, Vec2 } from './types';
import {
bushesForLevel,
bushesForDay,
DROP_COLLECT_ARC_HEIGHT,
DROP_COLLECT_DURATION,
DROP_EJECT_RADIUS,
@@ -12,18 +12,37 @@ import {
ENTITY_MIN_DISTANCE_FROM_CENTER,
INTERACTION_RADIUS,
LOGS_PER_TREE,
treesForLevel,
treesForDay,
TWIGS_PER_BUSH,
TWIGS_PER_TREE,
} from './constants';
import { loadSave, writeSave } from './persistence';
import { BLOOD_MOON_BANNER_SECONDS, isBloodMoonNight, isNightHour, NIGHT_SPEED, nightBelongsToDay } from './daynight';
import { clearSave, loadSave, writeSave } from './persistence';
import {
BLOOD_MOON_SIZE_MULTIPLIER,
MEAT_VALUE,
RABBIT_ACTION_DURATION,
RABBIT_HIT_RADIUS,
RABBIT_JUMP_ARC_HEIGHT,
RABBIT_JUMP_DISTANCE,
RABBIT_MAX_HP,
RABBIT_MIN_DISTANCE_FROM_CHARACTER,
RABBIT_NIGHT_JUMP_SPREAD_DEGREES,
RABBIT_PAUSE_DURATION,
RABBIT_STRIKE_DAMAGE,
RABBIT_STRIKE_RANGE,
rabbitsForDay,
} from './rabbits';
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: ResourceType[] = ['log', 'twig'];
const RESOURCE_ORDER: NodeResourceType[] = ['log', 'twig'];
function randomId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2)}`;
@@ -34,19 +53,30 @@ function isDepleted(node: ResourceNode): boolean {
}
/** The next resource a node should yield when chopped, or null once it's fully depleted. */
function nextResource(node: ResourceNode): ResourceType | null {
function nextResource(node: ResourceNode): NodeResourceType | null {
return RESOURCE_ORDER.find((resource) => node.remaining[resource] > 0) ?? null;
}
export class GameStore {
level = 1;
day = 1;
/** In-game clock time (0-24, fractional), starts at noon on a fresh game. */
hour = 12;
character: Vec2 = { x: 0, y: 0 };
nodes: ResourceNode[] = [];
resourceDrops: ResourceDrop[] = [];
inventory: Inventory = { logs: 0, twigs: 0, coins: 0 };
rabbits: RabbitState[] = [];
/** Set once the character's hp reaches 0 — freezes the game (see tick()). */
isDead = false;
/** Current hp; starts each fresh game at characterMaxHp. */
characterHp = 5;
inventory: Inventory = { logs: 0, twigs: 0, meat: 0, coins: 0 };
heldNodeId: string | null = null;
/** Free-form walk destination set by pressing on empty map, mutually exclusive with heldNodeId. */
heldRabbitId: string | null = null;
/** Free-form walk destination set by pressing on empty map, mutually exclusive with
* heldNodeId/heldRabbitId. */
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,
@@ -54,14 +84,22 @@ export class GameStore {
twigValue: 1,
pickupRadius: 1,
coinSpeed: 1,
daySpeed: 1,
maxHealth: 1,
attackDamage: 1,
};
private chopProgress = 0;
private rabbitAttackProgress = 0;
private healthRegenProgress = 0;
private coinProgress = 0;
private saveProgress = 0;
private lastTimestamp: number | null = null;
private rafId: number | null = null;
/** Map dimensions from the last initialize(), reused to spawn each new level's nodes. */
/** The last blood-moon "night day" (see nightBelongsToDay) the banner was shown for, so it only
* pops up once per blood moon rather than every frame it stays true. */
private lastAnnouncedBloodMoonDay = -1;
/** Map dimensions from the last initialize(), reused to grow each new day's nodes. */
private mapSize: { width: number; height: number } | null = null;
constructor() {
@@ -80,6 +118,10 @@ export class GameStore {
return this.nodes.find((node) => node.id === this.heldNodeId);
}
get heldRabbit(): RabbitState | undefined {
return this.rabbits.find((rabbit) => rabbit.id === this.heldRabbitId);
}
/** Whether the character is currently in chopping range of the held node (as opposed to still walking to it). */
get isChopping(): boolean {
const node = this.heldNode;
@@ -88,10 +130,20 @@ export class GameStore {
return Math.hypot(node.x - this.character.x, node.y - this.character.y) <= INTERACTION_RADIUS;
}
/** Whether the character is actively walking (toward a held node or a free-form move target). */
/** Whether the character is currently in hitting range of the held rabbit. */
get isAttackingRabbit(): boolean {
const rabbit = this.heldRabbit;
if (!rabbit)
return false;
return Math.hypot(rabbit.x - this.character.x, rabbit.y - this.character.y) <= this.rabbitHitRadius;
}
/** Whether the character is actively walking (toward a held node/rabbit or a free-form move target). */
get isMoving(): boolean {
if (this.heldNodeId)
return !this.isChopping;
if (this.heldRabbitId)
return !this.isAttackingRabbit;
if (this.moveTarget)
return Math.hypot(this.moveTarget.x - this.character.x, this.moveTarget.y - this.character.y) > 0.5;
return false;
@@ -123,9 +175,42 @@ export class GameStore {
return UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
}
/** Whether every node on the current level has been chopped down and every resource collected. */
get canAdvanceLevel(): boolean {
return this.nodes.length === 0 && this.resourceDrops.length === 0;
/** Game-hours per real second while it's day (morning/day/evening) — night has its own fixed speed. */
get daySpeedHoursPerSecond(): number {
return UPGRADES.daySpeed.effectAtLevel(this.upgradeLevels.daySpeed);
}
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 {
return UPGRADES.attackDamage.effectAtLevel(this.upgradeLevels.attackDamage);
}
get isNight(): boolean {
return isNightHour(this.hour);
}
get isBloodMoon(): boolean {
return isBloodMoonNight(this.day, this.hour);
}
get bloodMoonBannerVisible(): boolean {
return this.bloodMoonBannerTimer > 0;
}
/** How close the character must be, by night, for a rabbit to strike instead of jumping toward
* them — bigger during a blood moon, matching rabbits' bigger size then. */
get rabbitStrikeRange(): number {
return RABBIT_STRIKE_RANGE * (this.isBloodMoon ? BLOOD_MOON_SIZE_MULTIPLIER : 1);
}
/** How close the character must get to hit a rabbit — bigger during a blood moon, matching
* rabbits' bigger size then. */
get rabbitHitRadius(): number {
return RABBIT_HIT_RADIUS * (this.isBloodMoon ? BLOOD_MOON_SIZE_MULTIPLIER : 1);
}
/** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */
@@ -141,18 +226,25 @@ export class GameStore {
}
/** (Re)initializes the game for a map of the given size. Restores a saved game (character/nodes/
* drops/progress) if one exists; otherwise places the character in the center and scatters a
* fresh level's worth of trees and bushes. */
* drops/progress) if one exists; otherwise places the character in the center and scatters day
* 1's trees and bushes. */
initialize(width: number, height: number) {
this.mapSize = { width, height };
this.heldNodeId = null;
this.heldRabbitId = null;
this.moveTarget = null;
this.chopProgress = 0;
this.rabbitAttackProgress = 0;
this.healthRegenProgress = 0;
this.coinProgress = 0;
this.saveProgress = 0;
this.isDead = false;
this.bloodMoonBannerTimer = 0;
this.lastAnnouncedBloodMoonDay = -1;
const save = loadSave();
this.level = save?.level ?? 1;
this.day = save?.day ?? 1;
this.hour = save?.hour ?? 12;
if (save?.character && save.nodes && save.resourceDrops) {
this.character = { ...save.character };
@@ -164,61 +256,138 @@ export class GameStore {
this.resourceDrops = [];
}
this.inventory = save ? { ...save.inventory } : { logs: 0, twigs: 0, coins: 0 };
// Rabbits are restored/spawned independently of the world-state branch above so a save from
// before rabbits existed still loads fine — it just gets a freshly spawned batch of them.
// Every field gets an explicit fallback since a save from before the current rabbit shape
// (combat hp, or the jump/strike action cycle) won't have all of these.
this.rabbits = save?.rabbits
? save.rabbits.map((rabbit) => ({
id: rabbit.id,
x: rabbit.x,
y: rabbit.y,
// 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),
action: rabbit.action ?? 'idle',
actionTime: rabbit.actionTime ?? 0,
jumpFromX: rabbit.jumpFromX ?? rabbit.x,
jumpFromY: rabbit.jumpFromY ?? rabbit.y,
jumpToX: rabbit.jumpToX ?? rabbit.x,
jumpToY: rabbit.jumpToY ?? rabbit.y,
}))
: this.spawnRabbits(width, height, rabbitsForDay(this.day));
// 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 };
}
/** Advances to the next level once every node is chopped and every resource collected: respawns
* a fresh batch of trees and bushes and clears the ground of drops. */
nextLevel() {
if (!this.canAdvanceLevel || !this.mapSize)
return;
this.level += 1;
this.heldNodeId = null;
this.moveTarget = null;
this.chopProgress = 0;
this.nodes = this.spawnNodes(this.mapSize.width, this.mapSize.height);
this.resourceDrops = [];
this.save();
// 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);
}
/** Persists the full game (world state + progress) to localStorage right away. */
save() {
writeSave({
version: 2,
version: 3,
inventory: { ...this.inventory },
upgradeLevels: { ...this.upgradeLevels },
character: { ...this.character },
nodes: this.nodes.map((node) => ({ ...node, remaining: { ...node.remaining } })),
resourceDrops: this.resourceDrops.map((drop) => ({ ...drop, flight: drop.flight ? { ...drop.flight } : undefined })),
level: this.level,
rabbits: this.rabbits.map((rabbit) => ({ ...rabbit })),
day: this.day,
hour: this.hour,
characterHp: this.characterHp,
});
}
/** Scatters this level's trees and bushes, keeping every node clear of the character's starting
* spot, the map edge, and each other regardless of kind. */
/** Scatters day 1's trees and bushes, keeping every node clear of the character's starting spot,
* the map edge, and each other regardless of kind. */
private spawnNodes(width: number, height: number): ResourceNode[] {
const treePositions = this.spawnPositions(treesForLevel(this.level), width, height, []);
const bushPositions = this.spawnPositions(bushesForLevel(this.level), width, height, treePositions);
const treePositions = this.spawnPositions(treesForDay(this.day), width, height, []);
const bushPositions = this.spawnPositions(bushesForDay(this.day), width, height, treePositions);
return [...this.buildTrees(treePositions), ...this.buildBushes(bushPositions)];
}
const trees: ResourceNode[] = treePositions.map((pos) => ({
/** A new day doesn't clear existing, unharvested plants — it only tops each kind up (at random
* new spots) to that day's cap, so trees/bushes the player hasn't gotten to yet persist. */
private growNewNodesForDay() {
if (!this.mapSize)
return;
const { width, height } = this.mapSize;
const existing = this.nodes.map((node): Vec2 => ({ x: node.x, y: node.y }));
const treeDeficit = Math.max(0, treesForDay(this.day) - this.nodes.filter((n) => n.kind === 'tree').length);
const bushDeficit = Math.max(0, bushesForDay(this.day) - this.nodes.filter((n) => n.kind === 'bush').length);
const newTreePositions = this.spawnPositions(treeDeficit, width, height, existing);
const newBushPositions = this.spawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions]);
const newNodes = [...this.buildTrees(newTreePositions), ...this.buildBushes(newBushPositions)];
if (newNodes.length > 0)
this.nodes = [...this.nodes, ...newNodes];
}
private spawnRabbits(width: number, height: number, count: number): RabbitState[] {
const positions = this.spawnPositions(count, width, height, []);
return positions.map((pos) => this.buildRabbit(pos));
}
/** Tops the rabbit population up to this day's cap (never removes any — the cap only ever
* grows), at random new spots. */
private growRabbitsForDay() {
if (!this.mapSize)
return;
const { width, height } = this.mapSize;
const deficit = Math.max(0, rabbitsForDay(this.day) - this.rabbits.length);
if (deficit === 0)
return;
const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y }));
const positions = this.spawnPositions(deficit, width, height, existing);
this.rabbits = [...this.rabbits, ...positions.map((pos) => this.buildRabbit(pos))];
}
private buildRabbit(pos: Vec2): RabbitState {
return {
id: randomId('rabbit'),
x: pos.x,
y: pos.y,
z: 0,
hp: RABBIT_MAX_HP,
action: 'idle',
// Staggered so freshly spawned rabbits don't all start their first action in lockstep.
actionTime: Math.random() * RABBIT_PAUSE_DURATION,
jumpFromX: pos.x,
jumpFromY: pos.y,
jumpToX: pos.x,
jumpToY: pos.y,
};
}
private buildTrees(positions: Vec2[]): ResourceNode[] {
return positions.map((pos) => ({
id: randomId('tree'),
kind: 'tree',
x: pos.x,
y: pos.y,
remaining: { log: LOGS_PER_TREE, twig: TWIGS_PER_TREE },
}));
const bushes: ResourceNode[] = bushPositions.map((pos) => ({
}
private buildBushes(positions: Vec2[]): ResourceNode[] {
return positions.map((pos) => ({
id: randomId('bush'),
kind: 'bush',
x: pos.x,
y: pos.y,
remaining: { log: 0, twig: TWIGS_PER_BUSH },
}));
return [...trees, ...bushes];
}
private spawnPositions(count: number, width: number, height: number, existing: Vec2[]): Vec2[] {
@@ -248,21 +417,36 @@ export class GameStore {
holdNode(id: string) {
if (this.nodes.some((node) => node.id === id)) {
this.moveTarget = null;
this.heldRabbitId = null;
this.rabbitAttackProgress = 0;
this.heldNodeId = id;
}
}
holdRabbit(id: string) {
if (this.rabbits.some((rabbit) => rabbit.id === id)) {
this.moveTarget = null;
this.heldNodeId = null;
this.rabbitAttackProgress = 0;
this.heldRabbitId = id;
}
}
/** Starts walking freely toward an arbitrary point on the map (e.g. a click on empty ground). */
moveTo(x: number, y: number) {
this.heldNodeId = null;
this.heldRabbitId = null;
this.chopProgress = 0;
this.rabbitAttackProgress = 0;
this.moveTarget = { x, y };
}
release() {
this.heldNodeId = null;
this.heldRabbitId = null;
this.moveTarget = null;
this.chopProgress = 0;
this.rabbitAttackProgress = 0;
}
start() {
@@ -291,13 +475,193 @@ export class GameStore {
}
private tick(deltaTime: number) {
if (this.isDead)
return;
this.updateClock(deltaTime);
this.updateBloodMoon(deltaTime);
this.updateMovementAndChopping(deltaTime);
this.updateRabbits(deltaTime);
this.updateHealthRegen(deltaTime);
this.updateDropFlights(deltaTime);
this.updatePickups();
this.updateCoinConversion(deltaTime);
this.updateAutosave(deltaTime);
}
/** Shows the "КРОВАВАЯ ЛУНА!" banner for a few seconds right as a blood moon night begins. */
private updateBloodMoon(deltaTime: number) {
if (this.bloodMoonBannerTimer > 0)
this.bloodMoonBannerTimer = Math.max(0, this.bloodMoonBannerTimer - deltaTime);
if (!this.isBloodMoon)
return;
const nightDay = nightBelongsToDay(this.day, this.hour);
if (nightDay === this.lastAnnouncedBloodMoonDay)
return;
this.lastAnnouncedBloodMoonDay = nightDay;
this.bloodMoonBannerTimer = BLOOD_MOON_BANNER_SECONDS;
}
/** Hp regenerates a whole point at a time (same accumulate-then-spend cadence as chopProgress,
* coinProgress, ...) while no rabbit is close enough, at night, to strike — regenerating while
* actively hunted would undercut the threat, so proximity alone pauses it even between one
* rabbit's individual strikes. Actual damage happens elsewhere, in landRabbitStrike, once a
* rabbit's own strike action completes. */
private updateHealthRegen(deltaTime: number) {
const isThreatened = this.isNight && this.rabbits.some(
(rabbit) => Math.hypot(rabbit.x - this.character.x, rabbit.y - this.character.y) <= this.rabbitStrikeRange,
);
if (isThreatened || this.characterHp >= this.characterMaxHp) {
this.healthRegenProgress = 0;
return;
}
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;
}
}
/** A rabbit's own damage lands the instant its strike action begins (see
* startNextRabbitAction) rather than draining continuously or waiting for the action to play
* out — the ~1s of "striking" afterward is just the rabbit committed to that action and unable
* 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);
if (this.characterHp <= 0) {
this.isDead = true;
clearSave();
}
}
/** Advances the in-game clock — at the (fixed) night speed during night hours, or the
* (upgradable) day speed otherwise — rolling over into a new day, which grows fresh plants and
* rabbits up to the new day's caps, whenever it passes midnight. */
private updateClock(deltaTime: number) {
const speed = this.isNight ? NIGHT_SPEED : this.daySpeedHoursPerSecond;
this.hour += speed * deltaTime;
while (this.hour >= 24) {
this.hour -= 24;
this.day += 1;
this.growNewNodesForDay();
this.growRabbitsForDay();
}
}
/** Each rabbit independently cycles: pause (~1s, deciding its next action) → jump or strike
* (~1s, committed — see updateRabbitAction) → pause → ... A rabbit does nothing but the current
* action while it's mid-action; it only reacts to the character's position at the instant each
* pause ends. */
private updateRabbits(deltaTime: number) {
if (this.rabbits.length === 0 || !this.mapSize)
return;
for (const rabbit of this.rabbits)
this.updateRabbitAction(rabbit, deltaTime);
}
private updateRabbitAction(rabbit: RabbitState, deltaTime: number) {
rabbit.actionTime += deltaTime;
if (rabbit.action === 'idle') {
if (rabbit.actionTime < RABBIT_PAUSE_DURATION)
return;
rabbit.actionTime -= RABBIT_PAUSE_DURATION;
this.startNextRabbitAction(rabbit);
return;
}
if (rabbit.action === 'jumping') {
const t = Math.min(1, rabbit.actionTime / RABBIT_ACTION_DURATION);
rabbit.x = rabbit.jumpFromX + (rabbit.jumpToX - rabbit.jumpFromX) * t;
rabbit.y = rabbit.jumpFromY + (rabbit.jumpToY - rabbit.jumpFromY) * t;
// Same parabola as a resource drop's flight: 0 at both ends, peaking at the midpoint.
rabbit.z = RABBIT_JUMP_ARC_HEIGHT * 4 * t * (1 - t);
if (rabbit.actionTime >= RABBIT_ACTION_DURATION) {
rabbit.actionTime -= RABBIT_ACTION_DURATION;
rabbit.action = 'idle';
rabbit.z = 0;
}
return;
}
// striking (damage already landed when the action began — see startNextRabbitAction)
if (rabbit.actionTime >= RABBIT_ACTION_DURATION) {
rabbit.actionTime -= RABBIT_ACTION_DURATION;
rabbit.action = 'idle';
}
}
/** Chooses what a rabbit does next, right as its pause ends: strike if the character is
* already in range at night, otherwise jump (toward the character at night, or a random spot
* that respects the minimum distance from them by day). */
private startNextRabbitAction(rabbit: RabbitState) {
const distance = Math.hypot(this.character.x - rabbit.x, this.character.y - rabbit.y);
if (this.isNight && distance <= this.rabbitStrikeRange) {
rabbit.action = 'striking';
this.landRabbitStrike();
return;
}
rabbit.action = 'jumping';
rabbit.jumpFromX = rabbit.x;
rabbit.jumpFromY = rabbit.y;
const target = this.pickRabbitJumpTarget(rabbit, distance);
rabbit.jumpToX = target.x;
rabbit.jumpToY = target.y;
}
private pickRabbitJumpTarget(rabbit: RabbitState, distanceToCharacter: number): Vec2 {
const { width, height } = this.mapSize!;
let angle: number;
let jumpDistance: number;
if (this.isNight) {
// "Roughly toward" the character — a direct beeline with some random deviation — and never
// overshooting past them.
const towardCharacter = Math.atan2(this.character.y - rabbit.y, this.character.x - rabbit.x);
const spread = RABBIT_NIGHT_JUMP_SPREAD_DEGREES * (Math.PI / 180);
angle = towardCharacter + (Math.random() * 2 - 1) * spread;
jumpDistance = Math.min(RABBIT_JUMP_DISTANCE, distanceToCharacter);
} else {
angle = this.pickDayJumpAngle(rabbit);
jumpDistance = RABBIT_JUMP_DISTANCE;
}
const targetX = Math.min(Math.max(rabbit.x + Math.cos(angle) * jumpDistance, ENTITY_EDGE_MARGIN), width - ENTITY_EDGE_MARGIN);
const targetY = Math.min(Math.max(rabbit.y + Math.sin(angle) * jumpDistance, ENTITY_EDGE_MARGIN), height - ENTITY_EDGE_MARGIN);
return { x: targetX, y: targetY };
}
/** A random direction whose landing spot keeps at least RABBIT_MIN_DISTANCE_FROM_CHARACTER away
* from the character. Jumping directly away from the character is the landing spot that
* maximizes distance from them for a fixed jump distance — so if even that can't clear the
* minimum (the character is close enough that no direction can), it's used immediately as the
* next best thing, rather than wasting attempts on random angles that are all guaranteed to
* fail too. Otherwise, a handful of random attempts (typically satisfied well within that many,
* since only a narrow cone of angles points back toward the character) picks a natural-looking
* direction instead of always fleeing in a perfectly straight line. */
private pickDayJumpAngle(rabbit: RabbitState): number {
const awayAngle = Math.atan2(rabbit.y - this.character.y, rabbit.x - this.character.x);
const maxReachableDistance = Math.hypot(
this.character.x - (rabbit.x + Math.cos(awayAngle) * RABBIT_JUMP_DISTANCE),
this.character.y - (rabbit.y + Math.sin(awayAngle) * RABBIT_JUMP_DISTANCE),
);
if (maxReachableDistance < RABBIT_MIN_DISTANCE_FROM_CHARACTER)
return awayAngle;
for (let attempt = 0; attempt < 8; attempt++) {
const angle = Math.random() * Math.PI * 2;
const targetX = rabbit.x + Math.cos(angle) * RABBIT_JUMP_DISTANCE;
const targetY = rabbit.y + Math.sin(angle) * RABBIT_JUMP_DISTANCE;
const distanceToCharacter = Math.hypot(this.character.x - targetX, this.character.y - targetY);
if (distanceToCharacter >= RABBIT_MIN_DISTANCE_FROM_CHARACTER)
return angle;
}
return awayAngle;
}
private updateAutosave(deltaTime: number) {
this.saveProgress += deltaTime;
if (this.saveProgress < AUTOSAVE_INTERVAL)
@@ -313,6 +677,12 @@ export class GameStore {
return;
}
const rabbit = this.heldRabbit;
if (rabbit) {
this.walkTowardRabbit(rabbit, deltaTime);
return;
}
if (this.moveTarget) {
this.walkTowardPoint(this.moveTarget, deltaTime);
return;
@@ -379,23 +749,68 @@ export class GameStore {
}
}
// Ejects a resource on a ballistic arc to a random spot within a circle around the node; it
private walkTowardRabbit(rabbit: RabbitState, deltaTime: number) {
const dx = rabbit.x - this.character.x;
const dy = rabbit.y - this.character.y;
const distance = Math.hypot(dx, dy);
const hitRadius = this.rabbitHitRadius;
// Unlike a stationary node, a rabbit keeps moving after the character reaches hitting
// range — by day it's actively fleeing, so it can outrun a character that stops advancing
// the moment it's nominally "close enough." Always closing the remaining gap (rather than
// parking at hitRadius the way walkTowardNode does at INTERACTION_RADIUS) keeps the chase
// from stalling out just past the boundary every other frame.
if (distance > 0.5) {
const step = Math.min(this.characterSpeed * deltaTime, distance);
const ratio = step / distance;
this.character = {
x: this.character.x + dx * ratio,
y: this.character.y + dy * ratio,
};
}
if (distance > hitRadius) {
this.rabbitAttackProgress = 0;
return;
}
this.rabbitAttackProgress += this.attackDamagePerSecond * deltaTime;
while (this.rabbitAttackProgress >= 1 && rabbit.hp > 0) {
this.rabbitAttackProgress -= 1;
rabbit.hp -= 1;
if (rabbit.hp <= 0) {
this.killRabbit(rabbit);
break;
}
}
}
private killRabbit(rabbit: RabbitState) {
this.rabbits = this.rabbits.filter((r) => r.id !== rabbit.id);
if (this.heldRabbitId === rabbit.id) {
this.heldRabbitId = null;
this.rabbitAttackProgress = 0;
}
this.ejectDrop(rabbit, 'meat');
}
// Ejects a resource on a ballistic arc to a random spot within a circle around the origin; it
// only becomes pickable once it lands (see updateDropFlights/updatePickups).
private ejectDrop(node: ResourceNode, resource: ResourceType) {
private ejectDrop(origin: Vec2, resource: ResourceType) {
const angle = Math.random() * Math.PI * 2;
const landDistance = Math.random() * DROP_EJECT_RADIUS;
const toX = node.x + Math.cos(angle) * landDistance;
const toY = node.y + Math.sin(angle) * landDistance;
const toX = origin.x + Math.cos(angle) * landDistance;
const toY = origin.y + Math.sin(angle) * landDistance;
this.resourceDrops.push({
id: randomId('drop'),
resource,
x: node.x,
y: node.y,
x: origin.x,
y: origin.y,
z: 0,
phase: 'falling',
flight: {
fromX: node.x,
fromY: node.y,
fromX: origin.x,
fromY: origin.y,
toX,
toY,
elapsed: 0,
@@ -439,8 +854,10 @@ export class GameStore {
(collectedIds ??= new Set()).add(drop.id);
if (drop.resource === 'log')
this.inventory.logs += 1;
else
else if (drop.resource === 'twig')
this.inventory.twigs += 1;
else
this.inventory.meat += 1;
}
}
@@ -473,17 +890,18 @@ export class GameStore {
}
/** Converts held resources into coins. Every resource type that still has stock loses one unit
* and pays out its coin value on the same interval, so logs and twigs drain side by side rather
* than one type queuing behind the other. */
* and pays out its coin value on the same interval, so logs/twigs/meat drain side by side rather
* than one type queuing behind the others. */
private updateCoinConversion(deltaTime: number) {
if (this.inventory.logs <= 0 && this.inventory.twigs <= 0) {
const { logs, twigs, meat } = this.inventory;
if (logs <= 0 && twigs <= 0 && meat <= 0) {
this.coinProgress = 0;
return;
}
const interval = this.coinConversionInterval;
this.coinProgress += deltaTime;
while (this.coinProgress >= interval && (this.inventory.logs > 0 || this.inventory.twigs > 0)) {
while (this.coinProgress >= interval && (this.inventory.logs > 0 || this.inventory.twigs > 0 || this.inventory.meat > 0)) {
this.coinProgress -= interval;
if (this.inventory.logs > 0) {
this.inventory.logs -= 1;
@@ -493,6 +911,10 @@ export class GameStore {
this.inventory.twigs -= 1;
this.inventory.coins += this.coinsPerTwig;
}
if (this.inventory.meat > 0) {
this.inventory.meat -= 1;
this.inventory.coins += MEAT_VALUE;
}
}
}
}
+6 -6
View File
@@ -1,11 +1,11 @@
/** Number of trees spawned on a given level. */
export function treesForLevel(level: number): number {
return Math.max(0, level - 2);
/** Number of trees the map should have by a given day. */
export function treesForDay(day: number): number {
return Math.max(0, day - 2);
}
/** Number of bushes spawned on a given level. */
export function bushesForLevel(level: number): number {
return Math.ceil(level / 2);
/** Number of bushes the map should have by a given day. */
export function bushesForDay(day: number): number {
return Math.ceil(day / 2);
}
export const LOGS_PER_TREE = 5;
+86
View File
@@ -0,0 +1,86 @@
/** Night spans 22:00–06:00 (8 game-hours); the rest of the clock ("day", covering morning/midday/
* evening) is the other time-speed regime — see GameStore.updateClock. Lighting still varies
* continuously across all of these (see overlayColorForHour) even though only two speeds exist. */
export const NIGHT_START_HOUR = 22;
export const NIGHT_END_HOUR = 6;
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;
export function isNightHour(hour: number): boolean {
return hour >= NIGHT_START_HOUR || hour < NIGHT_END_HOUR;
}
export const BLOOD_MOON_INTERVAL_DAYS = 5;
/** Seconds the "КРОВАВАЯ ЛУНА!" banner stays on screen once a blood moon night begins. */
export const BLOOD_MOON_BANNER_SECONDS = 4;
/** A single night spans a day-counter rollover (22:00 of day N through 06:00 of day N+1) — this
* says which day that night belongs to throughout, for period-based night events. */
export function nightBelongsToDay(day: number, hour: number): number {
return hour >= NIGHT_START_HOUR ? day : day - 1;
}
export function isBloodMoonNight(day: number, hour: number): boolean {
if (!isNightHour(hour))
return false;
const nightDay = nightBelongsToDay(day, hour);
return nightDay > 0 && nightDay % BLOOD_MOON_INTERVAL_DAYS === 0;
}
/** Formats an in-game clock hour (0-24, fractional) as "HH:MM". */
export function formatClock(hour: number): string {
const totalMinutes = Math.round(hour * 60);
const wrapped = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60);
const h = Math.floor(wrapped / 60);
const m = wrapped % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
interface OverlayKeyframe {
hour: number;
r: number;
g: number;
b: number;
a: number;
}
// Night (dark) -> dawn (warm glow) -> day (clear) -> dusk (warm glow) -> night, keyed by hour.
const OVERLAY_KEYFRAMES: OverlayKeyframe[] = [
{ hour: 0, r: 10, g: 10, b: 35, a: 0.55 },
{ hour: 5, r: 10, g: 10, b: 35, a: 0.55 },
{ hour: 6, r: 255, g: 170, b: 110, a: 0.3 },
{ hour: 9, r: 0, g: 0, b: 0, a: 0 },
{ hour: 18, r: 0, g: 0, b: 0, a: 0 },
{ hour: 19, r: 255, g: 140, b: 70, a: 0.3 },
{ hour: 22, r: 10, g: 10, b: 35, a: 0.55 },
{ hour: 24, r: 10, g: 10, b: 35, a: 0.55 },
];
/** The ambient darkness/tint overlay for the given time of day, as a CSS rgba() string —
* interpolated piecewise-linearly between OVERLAY_KEYFRAMES. */
export function overlayColorForHour(hour: number): string {
const h = ((hour % 24) + 24) % 24;
let prev = OVERLAY_KEYFRAMES[0];
let next = OVERLAY_KEYFRAMES[OVERLAY_KEYFRAMES.length - 1];
for (let i = 0; i < OVERLAY_KEYFRAMES.length - 1; i++) {
if (h >= OVERLAY_KEYFRAMES[i].hour && h <= OVERLAY_KEYFRAMES[i + 1].hour) {
prev = OVERLAY_KEYFRAMES[i];
next = OVERLAY_KEYFRAMES[i + 1];
break;
}
}
const span = next.hour - prev.hour;
const t = span === 0 ? 0 : (h - prev.hour) / span;
const lerp = (a: number, b: number) => a + (b - a) * t;
const r = lerp(prev.r, next.r);
const g = lerp(prev.g, next.g);
const b = lerp(prev.b, next.b);
const a = lerp(prev.a, next.a);
return `rgba(${r.toFixed(0)}, ${g.toFixed(0)}, ${b.toFixed(0)}, ${a.toFixed(3)})`;
}
+11 -5
View File
@@ -1,10 +1,10 @@
import type { Inventory, ResourceDrop, ResourceNode, Vec2 } from './types';
import type { Inventory, RabbitState, ResourceDrop, ResourceNode, Vec2 } from './types';
import type { UpgradeId } from './upgrades';
const STORAGE_KEY = 'forest-save-v1';
export interface SaveData {
version: 2;
version: 3;
inventory: Inventory;
upgradeLevels: Record<UpgradeId, number>;
/** World state (character/nodes/drops) is optional so older saves without it still load fine —
@@ -12,8 +12,14 @@ export interface SaveData {
character?: Vec2;
nodes?: ResourceNode[];
resourceDrops?: ResourceDrop[];
/** Absent on older saves, which are treated as level 1. */
level?: number;
/** Absent on saves from before rabbits existed, which get a freshly spawned batch instead. */
rabbits?: RabbitState[];
/** Absent on older saves, which are treated as day 1. */
day?: number;
/** In-game clock hour (0-24). Absent on older saves, which are treated as noon. */
hour?: number;
/** Absent on saves from before the character had hp, which default to a full characterMaxHp. */
characterHp?: number;
}
function isValidSave(value: unknown): value is SaveData {
@@ -21,7 +27,7 @@ function isValidSave(value: unknown): value is SaveData {
return false;
const data = value as Partial<SaveData>;
return (
data.version === 2
data.version === 3
&& typeof data.inventory === 'object' && data.inventory !== null
&& typeof data.inventory.logs === 'number'
&& typeof data.inventory.twigs === 'number'
+43
View File
@@ -0,0 +1,43 @@
/** Number of rabbits present on the map by a given day. */
export function rabbitsForDay(day: number): number {
return Math.max(0, 1 + Math.floor(day / 3));
}
/** How long a jump or strike action takes to play out. */
export const RABBIT_ACTION_DURATION = 1;
/** How long a rabbit pauses between actions (this is also when it decides its next one). */
export const RABBIT_PAUSE_DURATION = 1;
/** Max distance covered by a single jump. */
export const RABBIT_JUMP_DISTANCE = 80;
/** Peak height (px) of a jump's parabolic arc — same idea as a resource drop's arc when it flies
* off a chopped tree. */
export const RABBIT_JUMP_ARC_HEIGHT = 30;
/** By day, a rabbit won't choose a jump target closer than this to the character. */
export const RABBIT_MIN_DISTANCE_FROM_CHARACTER = 130;
/** How close the character must be, by night, for a rabbit to strike instead of jumping toward them. */
export const RABBIT_STRIKE_RANGE = 28;
/** How close the character must get to hit a rabbit. */
export const RABBIT_HIT_RADIUS = 40;
/** A rabbit's max HP (fixed — unlike the character's, there's no upgrade for it). */
export const RABBIT_MAX_HP = 3;
/** Damage dealt by a single completed strike (fixed — unlike the character's own attack damage,
* there's no upgrade for it). Multiple rabbits striking independently each deal this. */
export const RABBIT_STRIKE_DAMAGE = 1;
/** Random deviation (degrees, either side) from a direct line to the character for a night jump —
* "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;
+82
View File
@@ -200,3 +200,85 @@ export const COIN_SPRITE: PixelGrid = grid(
],
COIN_PALETTE,
);
const RABBIT_ROWS = [
'...OFO..OFO...',
'...OPO..OPO...',
'...OPO..OPO...',
'...OPO..OPO...',
'...OFOOOOFO...',
'...OFFFFFFO...',
'..FFFFFFFFFF..',
'.OFFEFFFFEFFO.',
'OFFFFFNNFFFFFO',
'OFFFFFFFFFFFFO',
'OffffffffffffO',
'.OffffffffffO.',
'..ffffffffff..',
'...OffffffO...',
];
const RABBIT_PALETTE = {
O: '#2a1f1a',
F: '#e8e0d0',
f: '#c9bfa8',
P: '#f0a8b0',
E: '#1b1611',
N: '#d97a86',
};
export const RABBIT_SPRITE: PixelGrid = grid(RABBIT_ROWS, RABBIT_PALETTE);
// Same shape, recolored — rabbits turn red and hunt the character at night (see GameStore).
const RABBIT_NIGHT_PALETTE = {
O: '#3a0e0e',
F: '#c94040',
f: '#8f2222',
P: '#f0a8b0',
E: '#0d0505',
N: '#3a0e0e',
};
export const RABBIT_NIGHT_SPRITE: PixelGrid = grid(RABBIT_ROWS, RABBIT_NIGHT_PALETTE);
const MEAT_PALETTE = {
O: '#4a1414',
R: '#c0392b',
r: '#8e2a20',
W: '#f0e0d0',
};
export const MEAT_SPRITE: PixelGrid = grid(
[
'..............',
'....OOOOO.....',
'..ORRRRRRRO...',
'.ORRWWRRRRRO..',
'.RRRRWWRRRRR..',
'ORRRRRRRRWRRO.',
'ORRRRRRRRWWRO.',
'OrrrrrrrrrrrO.',
'.rrrrrrrrrrr..',
'.OrrrrrrrrrO..',
'..OrrrrrrrO...',
'....OOOOO.....',
],
MEAT_PALETTE,
);
const HEART_PALETTE = {
O: '#5a0f0f',
F: '#e0333f',
};
export const HEART_SPRITE: PixelGrid = grid(
[
'.OO..OO.',
'OFFOOFFO',
'OFFFFFFO',
'.OFFFFO.',
'..OFFO..',
'...OO...',
],
HEART_PALETTE,
);
+30 -2
View File
@@ -3,7 +3,9 @@ export interface Vec2 {
y: number;
}
export type ResourceType = 'log' | 'twig';
export type ResourceType = 'log' | 'twig' | 'meat';
/** The resources a tree/bush can yield when chopped — unlike meat, which only comes from rabbits. */
export type NodeResourceType = 'log' | 'twig';
export type NodeKind = 'tree' | 'bush';
export interface ResourceNode {
@@ -12,7 +14,7 @@ export interface ResourceNode {
x: number;
y: number;
/** Remaining yield per resource type; the node disappears once every count reaches 0. */
remaining: Record<ResourceType, number>;
remaining: Record<NodeResourceType, number>;
}
export type DropPhase = 'falling' | 'landed' | 'collecting';
@@ -41,5 +43,31 @@ export interface ResourceDrop {
export interface Inventory {
logs: number;
twigs: number;
meat: number;
coins: number;
}
/** A rabbit is always doing exactly one of these: pausing between actions (deciding what to do
* next once the pause ends), mid-jump (interpolating from jumpFrom to jumpTo), or mid-strike
* (committed to hitting the character, landing once the action completes). It can't do anything
* else — including react to the character moving — until the current action finishes. */
export type RabbitAction = 'idle' | 'jumping' | 'striking';
export interface RabbitState {
id: string;
x: number;
y: number;
/** Visual height above ground for the jump's parabolic arc (same idea as ResourceDrop.z). 0
* except mid-jump. */
z: number;
/** Hits remaining before it dies and drops meat. */
hp: number;
action: RabbitAction;
/** Seconds elapsed within the current action (or pause, while 'idle'). */
actionTime: number;
/** Jump start/end; only meaningful while action === 'jumping'. */
jumpFromX: number;
jumpFromY: number;
jumpToX: number;
jumpToY: number;
}
+36 -1
View File
@@ -1,4 +1,13 @@
export type UpgradeId = 'moveSpeed' | 'chopSpeed' | 'logValue' | 'twigValue' | 'pickupRadius' | 'coinSpeed';
export type UpgradeId =
| 'moveSpeed'
| 'chopSpeed'
| 'logValue'
| 'twigValue'
| 'pickupRadius'
| 'coinSpeed'
| 'daySpeed'
| 'maxHealth'
| 'attackDamage';
export interface UpgradeDef {
id: UpgradeId;
@@ -61,6 +70,32 @@ export const UPGRADE_LIST: UpgradeDef[] = [
effectAtLevel: (level) => 1 / Math.pow(1.15, level - 1),
formatEffect: (v) => `${v.toFixed(2)} с/ресурс`,
},
{
id: 'daySpeed',
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)} ч/с`,
},
{
id: 'maxHealth',
name: 'Живучесть',
baseCost: 10,
costGrowth: 1.45,
effectAtLevel: (level) => 4 + level,
formatEffect: (v) => `${v} здоровья`,
},
{
id: 'attackDamage',
name: 'Сила удара по кролику',
baseCost: 8,
costGrowth: 1.4,
effectAtLevel: (level) => level,
formatEffect: (v) => `${v} урона/с`,
},
];
export const UPGRADES: Record<UpgradeId, UpgradeDef> = Object.fromEntries(