diff --git a/src/App.css b/src/App.css index 3299777..557d5bf 100644 --- a/src/App.css +++ b/src/App.css @@ -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 { diff --git a/src/App.tsx b/src/App.tsx index f97ca5b..185a2ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 setScreen('game')} />; } @@ -34,7 +40,7 @@ function App() {
- +
); diff --git a/src/components/CharacterEntity.tsx b/src/components/CharacterEntity.tsx new file mode 100644 index 0000000..71449d7 --- /dev/null +++ b/src/components/CharacterEntity.tsx @@ -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 ( + <> +
+
+ +
+ {store.characterHp < store.characterMaxHp && ( + + )} + + ); +}); + +export default CharacterEntity; diff --git a/src/components/GameMap.tsx b/src/components/GameMap.tsx index 1742a9f..081544a 100644 --- a/src/components/GameMap.tsx +++ b/src/components/GameMap.tsx @@ -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
; +}); -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
КРОВАВАЯ ЛУНА!
; +}); + +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 ( -
-
-
- -
+
+
Кролик поймал вас ночью...
+
); }); -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 ( -
-
-
- -
-
- ); -}); - -const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) { - return ( - <> -
-
- -
- - ); -}); - -function GameMap({ store }: Props) { +function GameMap({ store, onDeath }: Props) { const containerRef = useRef(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) { ))} + {store.rabbits.map((rabbit) => ( + + ))} + - {store.canAdvanceLevel && ( - - )} + + + + + )}
diff --git a/src/components/HealthBar.tsx b/src/components/HealthBar.tsx new file mode 100644 index 0000000..48a58de --- /dev/null +++ b/src/components/HealthBar.tsx @@ -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 ( +
+
+
+ ); +} + +export default HealthBar; diff --git a/src/components/NodeEntity.tsx b/src/components/NodeEntity.tsx new file mode 100644 index 0000000..6bae63b --- /dev/null +++ b/src/components/NodeEntity.tsx @@ -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 ( +
+
+
+ +
+
+ ); +}); + +export default NodeEntity; diff --git a/src/components/RabbitEntity.tsx b/src/components/RabbitEntity.tsx new file mode 100644 index 0000000..396b79c --- /dev/null +++ b/src/components/RabbitEntity.tsx @@ -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 ( +
+
+
+ +
+ {rabbit.hp < RABBIT_MAX_HP && ( + + )} +
+ ); +}); + +export default RabbitEntity; diff --git a/src/components/ResourceDropEntity.tsx b/src/components/ResourceDropEntity.tsx new file mode 100644 index 0000000..6e21a2d --- /dev/null +++ b/src/components/ResourceDropEntity.tsx @@ -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 ( +
+
+
+ +
+
+ ); +}); + +export default ResourceDropEntity; diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index b60ddab..492bc6f 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -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) {

Forest

- Уровень {store.level} + День {store.day}, {formatClock(store.hour)} +
+
+ + {store.characterHp}/{store.characterMaxHp}
@@ -24,6 +29,10 @@ function Topbar({ store, onExitToMenu }: Props) { {store.inventory.logs}
+
+ + {store.inventory.meat} +
{store.inventory.coins.toFixed(1)} diff --git a/src/game/GameStore.ts b/src/game/GameStore.ts index ffe9fd3..b8d075d 100644 --- a/src/game/GameStore.ts +++ b/src/game/GameStore.ts @@ -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 = { 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; + } } } } diff --git a/src/game/constants.ts b/src/game/constants.ts index f483152..af81288 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -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; diff --git a/src/game/daynight.ts b/src/game/daynight.ts new file mode 100644 index 0000000..d50d715 --- /dev/null +++ b/src/game/daynight.ts @@ -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)})`; +} diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 68222f4..a899ed7 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -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; /** 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; return ( - data.version === 2 + data.version === 3 && typeof data.inventory === 'object' && data.inventory !== null && typeof data.inventory.logs === 'number' && typeof data.inventory.twigs === 'number' diff --git a/src/game/rabbits.ts b/src/game/rabbits.ts new file mode 100644 index 0000000..5e6d7b3 --- /dev/null +++ b/src/game/rabbits.ts @@ -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; diff --git a/src/game/sprites.ts b/src/game/sprites.ts index eba9f6d..da0b58a 100644 --- a/src/game/sprites.ts +++ b/src/game/sprites.ts @@ -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, +); diff --git a/src/game/types.ts b/src/game/types.ts index 546bbae..d5ffc64 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -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; + remaining: Record; } 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; +} diff --git a/src/game/upgrades.ts b/src/game/upgrades.ts index 7a95829..6c6b099 100644 --- a/src/game/upgrades.ts +++ b/src/game/upgrades.ts @@ -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 = Object.fromEntries(