цикл день-ночь, кролики, кровавая луна
This commit is contained in:
+86
-25
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user