├── src └── game │ ├── __init__.py │ ├── domain │ ├── fish.py │ ├── crop.py │ ├── plot.py │ └── player.py │ ├── interfaces │ ├── game_system.py │ └── serializable.py │ ├── service │ ├── time_system.py │ ├── weather_system.py │ ├── fishing_system.py │ ├── crop_system.py │ ├── farm_system.py │ ├── daycycle_system.py │ ├── event_system.py │ ├── merchant_system.py │ ├── game_state.py │ └── tui_system.py │ ├── main.py │ └── utils │ └── constants.py ├── .vscode └── settings.json ├── web ├── assets │ ├── pattern.png │ ├── ready.svg │ ├── full_h.svg │ ├── empty_h.svg │ ├── date_label.svg │ └── half_h.svg ├── script.js ├── index.html └── style.css ├── Makefile ├── .gitignore ├── run.py ├── pyproject.toml ├── README.md └── poetry.lock /src/game/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.extraPaths": [ 3 | "./src/game" 4 | ] 5 | } -------------------------------------------------------------------------------- /web/assets/pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isabellaherman/terminal-farm/HEAD/web/assets/pattern.png -------------------------------------------------------------------------------- /src/game/domain/fish.py: -------------------------------------------------------------------------------- 1 | class Fish: 2 | def __init__(self, name: str, price: int): 3 | self.name = name 4 | self.price = price 5 | -------------------------------------------------------------------------------- /src/game/interfaces/game_system.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IGameSystem(ABC): 5 | @abstractmethod 6 | def update(self): 7 | pass 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: l f lf 2 | 3 | l: 4 | -poetry run ruff check --fix 5 | 6 | f: 7 | poetry run ruff format 8 | 9 | lf: l f 10 | 11 | 12 | run: 13 | python src/game/main.py 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | terminal_farmer_save.json 2 | 3 | __pycache__/ 4 | *.py[cod] 5 | *.pyo 6 | 7 | .ruff_cache/ 8 | 9 | venv/ 10 | .env/ 11 | .envrc 12 | 13 | .vscode/ 14 | .idea/ 15 | *.swp 16 | *.swo 17 | 18 | .DS_Store 19 | Thumbs.db 20 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | PYTHONPATH = str(Path(__file__).parent / "src" / "game") 5 | 6 | sys.path.append(PYTHONPATH) 7 | 8 | from main import main 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /src/game/interfaces/serializable.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | 5 | class ISerializable(ABC): 6 | @abstractmethod 7 | def to_dict(self) -> dict[str, Any]: 8 | pass 9 | 10 | @classmethod 11 | @abstractmethod 12 | def from_dict(cls, data: dict[str, Any]) -> Any: 13 | pass 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "terminal-farm" 3 | version = "0.1.0" 4 | description = "" 5 | authors = [ 6 | {name = "Your Name",email = "you@example.com"} 7 | ] 8 | readme = "README.md" 9 | requires-python = ">=3.11.0" 10 | dependencies = [ 11 | ] 12 | 13 | [tool.poetry] 14 | packages = [{include = "game", from = "src"}] 15 | 16 | 17 | [tool.poetry.group.dev.dependencies] 18 | ruff = "^0.11.7" 19 | 20 | [build-system] 21 | requires = ["poetry-core>=2.0.0,<3.0.0"] 22 | build-backend = "poetry.core.masonry.api" 23 | -------------------------------------------------------------------------------- /web/assets/ready.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/game/service/time_system.py: -------------------------------------------------------------------------------- 1 | from interfaces.game_system import IGameSystem 2 | from typing import Any 3 | 4 | 5 | class TimeSystem(IGameSystem): 6 | def __init__(self): 7 | self.day = 1 8 | 9 | def update(self): 10 | self.day += 1 11 | 12 | def get_day(self) -> int: 13 | return self.day 14 | 15 | def to_dict(self) -> dict[str, Any]: 16 | return {"day": self.day} 17 | 18 | @classmethod 19 | def from_dict(cls, data: dict[str, Any]) -> "TimeSystem": 20 | system = cls() 21 | system.day = data["day"] 22 | return system 23 | -------------------------------------------------------------------------------- /src/game/main.py: -------------------------------------------------------------------------------- 1 | import time 2 | import sys 3 | from service.game_state import GameState 4 | from service.tui_system import TerminalUI 5 | 6 | 7 | def main(): 8 | game_state: GameState = GameState() 9 | ui: TerminalUI = TerminalUI(game_state) 10 | 11 | if not game_state.load(): 12 | print("Starting new game...") 13 | time.sleep(1) 14 | 15 | try: 16 | ui.start_game_loop() 17 | except KeyboardInterrupt: 18 | game_state.save() 19 | print("\nGame saved automatically!") 20 | sys.exit() 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /web/assets/full_h.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/game/service/weather_system.py: -------------------------------------------------------------------------------- 1 | import random 2 | from interfaces.game_system import IGameSystem 3 | from typing import Any 4 | 5 | 6 | class WeatherSystem(IGameSystem): 7 | WEATHER_TYPES = ["sunny", "rainy", "cloudy", "windy"] 8 | 9 | def __init__(self): 10 | self.current_weather = "sunny" 11 | 12 | def update(self): 13 | if random.random() < 0.2: 14 | self.current_weather = random.choice(self.WEATHER_TYPES) 15 | 16 | def get_weather(self) -> str: 17 | return self.current_weather 18 | 19 | def to_dict(self) -> dict[str, Any]: 20 | return {"current_weather": self.current_weather} 21 | 22 | @classmethod 23 | def from_dict(cls, data: dict[str, Any]) -> "WeatherSystem": 24 | system = cls() 25 | system.current_weather = data["current_weather"] 26 | return system 27 | -------------------------------------------------------------------------------- /web/assets/empty_h.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /web/assets/date_label.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌱 Terminal Farm 2 | 3 | A simple terminal-based farming game in Python. 4 | Plant, grow, and harvest crops — all from your terminal! 5 | 6 | 7 | --- 8 | 9 | ## 🚀 How to Run 10 | 11 | 1. **Make sure you have Python 3 installed** 12 | 13 | ```bash 14 | python3 --version 15 | ``` 16 | 17 | 2. **Clone the repository** 18 | 19 | ```bash 20 | git clone https://github.com/isabellaherman/terminal-farm.git 21 | cd terminal-farm 22 | ``` 23 | 24 | 3. **Run the game** 25 | 26 | ```bash 27 | python3 run.py 28 | ``` 29 | 30 | --- 31 | 32 | ## 💾 Features 33 | 34 | - 🌽 Plant and harvest different crops 35 | - 🔓 Unlock new crops as you progress 36 | - 🌤️ Weather system and random events 37 | - 💾 Save and load game progress 38 | - 🐍 Pure Python, no external libraries 39 | - FEATURE ESPECIAL: SUPLA. (Jogo meio em português meio em inglês) 40 | 41 | --- 42 | 43 | ## ✅ Requirements 44 | 45 | - Python 3.x 46 | - A terminal that supports ANSI colors (Linux, macOS, or Windows Terminal) 47 | 48 | --- 49 | 50 | Have fun farming! 🌾 51 | -------------------------------------------------------------------------------- /src/game/domain/crop.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | from interfaces.serializable import ISerializable 5 | 6 | 7 | class Crop(ISerializable): 8 | def __init__( 9 | self, 10 | name: str, 11 | cost: int, 12 | growth_time: int, 13 | value: int, 14 | color: str, 15 | stamina_cost: float, 16 | ): 17 | self.name = name 18 | self.cost = cost 19 | self.growth_time = growth_time 20 | self.value = value 21 | self.color = color 22 | self.stamina_cost = stamina_cost 23 | 24 | def to_dict(self) -> dict[str, Any]: 25 | return { 26 | "name": self.name, 27 | "cost": self.cost, 28 | "growth_time": self.growth_time, 29 | "value": self.value, 30 | "color": self.color, 31 | "stamina_cost": self.stamina_cost, 32 | } 33 | 34 | @classmethod 35 | def from_dict(cls, data: dict[str, Any]) -> "Crop": 36 | return cls( 37 | name=data["name"], 38 | cost=data["cost"], 39 | growth_time=data["growth_time"], 40 | value=data["value"], 41 | color=data["color"], 42 | stamina_cost=data["stamina_cost"], 43 | ) 44 | -------------------------------------------------------------------------------- /src/game/service/fishing_system.py: -------------------------------------------------------------------------------- 1 | import random 2 | from domain.player import Player 3 | from domain.fish import Fish 4 | from utils.constants import FishingConstants 5 | 6 | 7 | class FishingSystem: 8 | def __init__(self, player: Player): 9 | self.player = player 10 | self.game = None 11 | self.caught_fish: list[Fish] = [] 12 | 13 | def fish(self) -> str: 14 | if not self.player.has_stamina(FishingConstants.STAMINA_TO_FISH): 15 | return "Not enough stamina to fish." 16 | 17 | self.player.use_stamina(FishingConstants.STAMINA_TO_FISH) 18 | 19 | fish: Fish = FishingConstants.FISH_TYPES[random.choice(FishingConstants.FISHES)] 20 | self.caught_fish.append(fish) 21 | return f"You caught a {fish.name} worth ${fish.price}!" 22 | 23 | def sell_all_fish(self) -> str: 24 | if len(self.caught_fish) <= 0: 25 | return "You got no fish to sell!" 26 | 27 | bonus_multiplier = ( 28 | 1.5 29 | if getattr(self, "game", None) 30 | and getattr(self.game, "fishing_bonus", False) 31 | else 1.0 32 | ) 33 | total = sum(int(fish.price * bonus_multiplier) for fish in self.caught_fish) 34 | self.player.earn_money(total) 35 | self.caught_fish = [] 36 | return f"Sold all fish for ${total}!" 37 | -------------------------------------------------------------------------------- /web/script.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | const plantButton = document.querySelector('.action.primary'); 3 | const backButton = document.querySelector('.back-button'); 4 | const actionsBlock = document.querySelector('.actions-block'); 5 | const mainMenu = document.querySelector('.main-menu'); 6 | const plantMenu = document.querySelector('.plant-menu'); 7 | const title = document.querySelector('.actions-title'); 8 | 9 | function showPlantMenu() { 10 | mainMenu.classList.add('hidden'); 11 | plantMenu.classList.remove('hidden'); 12 | backButton.classList.remove('hidden'); 13 | title.textContent = 'Choose a Seed'; 14 | } 15 | 16 | function showMainMenu() { 17 | mainMenu.classList.remove('hidden'); 18 | plantMenu.classList.add('hidden'); 19 | backButton.classList.add('hidden'); 20 | title.textContent = 'Actions'; 21 | } 22 | 23 | plantButton.addEventListener('click', showPlantMenu); 24 | backButton.addEventListener('click', showMainMenu); 25 | 26 | function renderHearts(vida) { 27 | const container = document.getElementById('hearts-container'); 28 | container.innerHTML = ''; 29 | 30 | const totalSlots = 16; 31 | 32 | for (let i = 0; i < totalSlots; i++) { 33 | const img = document.createElement('img'); 34 | 35 | if (vida >= i + 1) { 36 | img.src = 'assets/full_h.svg'; 37 | } else if (vida > i && vida < i + 1) { 38 | img.src = 'assets/half_h.svg'; 39 | } else { 40 | img.src = 'assets/empty_h.svg'; 41 | } 42 | 43 | img.alt = 'heart'; 44 | container.appendChild(img); 45 | } 46 | } 47 | 48 | renderHearts(12.5); 49 | }); -------------------------------------------------------------------------------- /src/game/service/crop_system.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any, List 2 | from domain.crop import Crop 3 | from interfaces.serializable import ISerializable 4 | 5 | 6 | class CropSystem(ISerializable): 7 | def __init__(self): 8 | self.available_crops = self._load_default_crops() 9 | self.unlocked_crops = ["wheat"] 10 | 11 | def _load_default_crops(self) -> dict[str, Crop]: 12 | return { 13 | "wheat": Crop("wheat", 10, 10, 20, "yellow", 0.5), 14 | "corn": Crop("corn", 20, 20, 45, "bright_yellow", 0.5), 15 | "pumpkin": Crop("pumpkin", 40, 40, 100, "orange", 1.0), 16 | "carrot": Crop("carrot", 15, 12, 25, "orange", 0.5), 17 | "eggplant": Crop("eggplant", 35, 30, 70, "purple", 1.0), 18 | "blueberry": Crop("blueberry", 60, 35, 90, "blue", 1.0), 19 | "lazy_ghost": Crop("lazy ghost seed [rare]", 0, 30, 100, "white", 0), 20 | } 21 | 22 | def get_crop(self, name: str) -> Optional[Crop]: 23 | return self.available_crops.get(name) 24 | 25 | def unlock_crop(self, name: str): 26 | if name not in self.available_crops or name in self.unlocked_crops: 27 | return None 28 | 29 | self.unlocked_crops.append(name) 30 | return f"NEW CROP UNLOCKED: {name.capitalize()}!" 31 | 32 | def get_unlocked_crops(self) -> List[Crop]: 33 | return [self.available_crops[name] for name in self.unlocked_crops] 34 | 35 | def to_dict(self) -> dict[str, Any]: 36 | return {"unlocked_crops": self.unlocked_crops} 37 | 38 | @classmethod 39 | def from_dict(cls, data: dict[str, Any]) -> "CropSystem": 40 | system = cls() 41 | system.unlocked_crops = data["unlocked_crops"] 42 | return system 43 | -------------------------------------------------------------------------------- /src/game/domain/plot.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, Any 3 | 4 | from interfaces.serializable import ISerializable 5 | from domain.crop import Crop 6 | 7 | 8 | class Plot(ISerializable): 9 | def __init__( 10 | self, crop: Optional[Crop] = None, planted_at: Optional[datetime] = None 11 | ): 12 | self.crop = crop 13 | self.planted_at = planted_at 14 | 15 | @property 16 | def is_empty(self) -> bool: 17 | return self.crop is None 18 | 19 | @property 20 | def growth_progress(self) -> float: 21 | if self.is_empty or self.planted_at is None: 22 | return 0.0 23 | 24 | elapsed = (datetime.now() - self.planted_at).total_seconds() 25 | return min(1.0, elapsed / self.crop.growth_time) 26 | 27 | @property 28 | def is_ready(self) -> bool: 29 | return self.growth_progress >= 1.0 30 | 31 | def plant(self, crop: Crop): 32 | self.crop = crop 33 | self.planted_at = datetime.now() 34 | 35 | def harvest(self) -> int: 36 | if self.is_empty or not self.is_ready: 37 | return 0 38 | 39 | value = self.crop.value 40 | self.crop = None 41 | self.planted_at = None 42 | return value 43 | 44 | def to_dict(self) -> dict[str, Any]: 45 | return { 46 | "crop": self.crop.to_dict() if self.crop else None, 47 | "planted_at": self.planted_at.isoformat() if self.planted_at else None, 48 | } 49 | 50 | @classmethod 51 | def from_dict(cls, data: dict[str, Any]) -> "Plot": 52 | crop_data = data["crop"] 53 | planted_at = data["planted_at"] 54 | 55 | return cls( 56 | crop=Crop.from_dict(crop_data) if crop_data else None, 57 | planted_at=datetime.fromisoformat(planted_at) if planted_at else None, 58 | ) 59 | -------------------------------------------------------------------------------- /src/game/service/farm_system.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | import random 3 | from typing import Optional, Tuple, Any 4 | from domain.crop import Crop 5 | from domain.plot import Plot 6 | from interfaces.serializable import ISerializable 7 | 8 | 9 | class FarmSystem(ISerializable): 10 | def __init__(self, size: int = 9): 11 | self.plots = [Plot() for _ in range(size)] 12 | 13 | def plant_crop(self, plot_index: int, crop: Crop): 14 | if 0 <= plot_index < len(self.plots): 15 | self.plots[plot_index].plant(crop) 16 | 17 | def harvest_ready_crops(self) -> int: 18 | total = 0 19 | for plot in self.plots: 20 | if not plot.is_empty and plot.is_ready: 21 | total += plot.harvest() 22 | return total 23 | 24 | def get_plot_status(self, plot_index: int) -> Tuple[Optional[Crop], float]: 25 | if plot_index not in range(len(self.plots)): 26 | return None, 0.0 27 | 28 | plot = self.plots[plot_index] 29 | return plot.crop, plot.growth_progress 30 | 31 | def damage_random_crop(self): 32 | occupied_plots = [i for i, plot in enumerate(self.plots) if not plot.is_empty] 33 | if not occupied_plots: 34 | return None 35 | 36 | plot_idx = random.choice(occupied_plots) 37 | self.plots[plot_idx] = Plot() 38 | return "A storm came! Some crops were damaged." 39 | 40 | def apply_growth_bonus(self, bonus_percent: float): 41 | for plot in self.plots: 42 | if not plot.is_empty and plot.planted_at: 43 | bonus_time = plot.crop.growth_time * (bonus_percent / 100) 44 | plot.planted_at -= timedelta(seconds=bonus_time) 45 | return "Sunny day bonus! Crops grow faster today." 46 | 47 | def to_dict(self) -> dict[str, Any]: 48 | return {"plots": [plot.to_dict() for plot in self.plots]} 49 | 50 | @classmethod 51 | def from_dict(cls, data: dict[str, Any]) -> "FarmSystem": 52 | farm = cls(size=len(data["plots"])) 53 | farm.plots = [Plot.from_dict(plot_data) for plot_data in data["plots"]] 54 | return farm 55 | -------------------------------------------------------------------------------- /src/game/service/daycycle_system.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any 3 | from interfaces.serializable import ISerializable 4 | from service.time_system import TimeSystem 5 | 6 | 7 | class DayCycleSystem(ISerializable): 8 | PARTS = ["morning", "afternoon", "evening", "night"] 9 | 10 | def __init__(self, time_system: TimeSystem): 11 | self.time_system = time_system 12 | self.current_part_index = 0 13 | self.last_update_time = datetime.now() 14 | self.durations = self.get_durations_for_current_season() 15 | 16 | def get_season(self) -> str: 17 | season_index = (self.time_system.day - 1) // 30 % 4 18 | return ["spring", "summer", "autumn", "winter"][season_index] 19 | 20 | def get_durations_for_current_season(self) -> dict[str, int]: 21 | season = self.get_season() 22 | 23 | if season == "summer": 24 | return {"morning": 4, "afternoon": 4, "evening": 2, "night": 3} 25 | elif season == "winter": 26 | return {"morning": 3, "afternoon": 3, "evening": 4, "night": 3} 27 | else: 28 | return {"morning": 3, "afternoon": 3, "evening": 3, "night": 3} 29 | 30 | def update(self): 31 | now = datetime.now() 32 | current_part = self.PARTS[self.current_part_index] 33 | duration_minutes = self.durations[current_part] 34 | 35 | if (now - self.last_update_time).total_seconds() >= duration_minutes * 60: 36 | self.current_part_index = (self.current_part_index + 1) % len(self.PARTS) 37 | self.last_update_time = now 38 | self.durations = self.get_durations_for_current_season() 39 | return f"Part of the day changed: {self.get_current_part().capitalize()}!" 40 | return None 41 | 42 | def get_current_part(self) -> str: 43 | return self.PARTS[self.current_part_index] 44 | 45 | def is_night(self) -> bool: 46 | return self.get_current_part() == "night" 47 | 48 | def to_dict(self): 49 | return { 50 | "current_part_index": self.current_part_index, 51 | "last_update_time": self.last_update_time.isoformat(), 52 | } 53 | 54 | @classmethod 55 | def from_dict(cls, data: dict[str, Any]): 56 | instance = cls(TimeSystem()) 57 | instance.current_part_index = data["current_part_index"] 58 | instance.last_update_time = datetime.fromisoformat(data["last_update_time"]) 59 | return instance 60 | -------------------------------------------------------------------------------- /src/game/domain/player.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Optional 3 | from interfaces.serializable import ISerializable 4 | 5 | 6 | class Player(ISerializable): 7 | def __init__( 8 | self, 9 | money: int = 50, 10 | stamina: float = 5.0, 11 | max_stamina: int = 5, 12 | last_sleep_time: Optional[datetime] = None, 13 | ): 14 | self.money = money 15 | self.stamina = stamina 16 | self.max_stamina = max_stamina 17 | self.last_sleep_time = last_sleep_time or datetime.now() 18 | self.has_farmdex = False 19 | self.has_lantern = False 20 | self.fossils_found = [] 21 | self.can_sleep_anytime = False 22 | 23 | def can_afford(self, amount: int) -> bool: 24 | return self.money >= amount 25 | 26 | def spend_money(self, amount: int): 27 | self.money -= amount 28 | 29 | def earn_money(self, amount: int): 30 | self.money += amount 31 | 32 | def has_stamina(self, amount: float) -> bool: 33 | return self.stamina >= amount 34 | 35 | def use_stamina(self, amount: float): 36 | self.stamina -= amount 37 | 38 | def restore_stamina(self, amount: float): 39 | self.stamina = min(self.max_stamina, self.stamina + amount) 40 | 41 | def full_restore(self): 42 | self.stamina = self.max_stamina 43 | 44 | def to_dict(self) -> dict[str, Any]: 45 | return { 46 | "money": self.money, 47 | "stamina": self.stamina, 48 | "max_stamina": self.max_stamina, 49 | "last_sleep_time": self.last_sleep_time.isoformat(), 50 | "has_farmdex": getattr(self, "has_farmdex", False), 51 | "has_lantern": getattr(self, "has_lantern", False), 52 | "fossils_found": getattr(self, "fossils_found", []), 53 | "can_sleep_anytime": getattr(self, "can_sleep_anytime", False), 54 | } 55 | 56 | @classmethod 57 | def from_dict(cls, data: dict[str, Any]) -> "Player": 58 | obj = cls( 59 | money=data["money"], 60 | stamina=data["stamina"], 61 | max_stamina=data["max_stamina"], 62 | last_sleep_time=datetime.fromisoformat(data["last_sleep_time"]), 63 | ) 64 | obj.has_farmdex = data.get("has_farmdex", False) 65 | obj.has_lantern = data.get("has_lantern", False) 66 | obj.fossils_found = data.get("fossils_found", []) 67 | obj.can_sleep_anytime = data.get("can_sleep_anytime", False) 68 | return obj 69 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "ruff" 5 | version = "0.11.7" 6 | description = "An extremely fast Python linter and code formatter, written in Rust." 7 | optional = false 8 | python-versions = ">=3.7" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c"}, 12 | {file = "ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee"}, 13 | {file = "ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada"}, 14 | {file = "ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64"}, 15 | {file = "ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201"}, 16 | {file = "ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6"}, 17 | {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4"}, 18 | {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e"}, 19 | {file = "ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63"}, 20 | {file = "ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502"}, 21 | {file = "ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92"}, 22 | {file = "ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94"}, 23 | {file = "ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6"}, 24 | {file = "ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6"}, 25 | {file = "ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26"}, 26 | {file = "ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a"}, 27 | {file = "ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177"}, 28 | {file = "ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4"}, 29 | ] 30 | 31 | [metadata] 32 | lock-version = "2.1" 33 | python-versions = ">=3.11.0" 34 | content-hash = "37c2f5357b160b03a40e43cdaca7286ce4317d9bb57f85adec98393244e6376a" 35 | -------------------------------------------------------------------------------- /src/game/utils/constants.py: -------------------------------------------------------------------------------- 1 | from domain.fish import Fish 2 | 3 | 4 | class TUIConstants: 5 | EMOJI_HEART = "♥" 6 | 7 | COLORS = { 8 | "reset": "\033[0m", 9 | "green": "\033[32m", 10 | "bright_green": "\033[1;32m", 11 | "yellow": "\033[33m", 12 | "bright_yellow": "\033[1;33m", 13 | "blue": "\033[34m", 14 | "bright_blue": "\033[1;34m", 15 | "cyan": "\033[36m", 16 | "bright_cyan": "\033[1;36m", 17 | "red": "\033[31m", 18 | "bright_red": "\033[1;31m", 19 | "orange": "\033[38;5;208m", 20 | "gray": "\033[90m", 21 | "white": "\033[97m", 22 | "pink": "\033[38;5;213m", 23 | "heart_red": "\033[38;5;161m", 24 | } 25 | 26 | BG_COLORS = { 27 | "reset": "\033[0m", 28 | "orange": "\033[48;5;94m", 29 | "yellow_pastel": "\033[48;5;187m", 30 | "gray": "\033[48;5;240m", 31 | "green": "\033[42m", 32 | "green_custom": "\033[48;5;115m", 33 | } 34 | 35 | WEATHER_ICONS = {"sunny": "☀️", "rainy": "🌧️", "cloudy": "☁️", "windy": "🌬️"} 36 | 37 | SEASON_ICONS = {"spring": "🌸", "summer": "☀️", "autumn": "🍂", "winter": "❄️"} 38 | 39 | 40 | class GameStateConstants: 41 | FOSSILS = [ 42 | "Tyrannosaurus", 43 | "Triceratops", 44 | "Velociraptor", 45 | "Brachiosaurus", 46 | "Stegosaurus", 47 | "Spinosaurus", 48 | "Ankylosaurus", 49 | "Parasaurolophus", 50 | "Allosaurus", 51 | "Diplodocus", 52 | "Iguanodon", 53 | "Archaeopteryx", 54 | "Pteranodon", 55 | "Deinonychus", 56 | "Megalosaurus", 57 | "Pachycephalosaurus", 58 | "Corythosaurus", 59 | "Oviraptor", 60 | "Plateosaurus", 61 | "Styracosaurus", 62 | "Suchomimus", 63 | "Troodon", 64 | "Carnotaurus", 65 | "Sauropelta", 66 | "Albertosaurus", 67 | "Mamenchisaurus", 68 | "Edmontosaurus", 69 | "Herrerasaurus", 70 | "Giganotosaurus", 71 | "Therizinosaurus", 72 | "Kentrosaurus", 73 | "Dilophosaurus", 74 | "Coelophysis", 75 | "Protoceratops", 76 | "Sinraptor", 77 | "Rugops", 78 | "Lambeosaurus", 79 | "Mononykus", 80 | "Torosaurus", 81 | "Rhabdodon", 82 | "Ouranosaurus", 83 | "Microceratus", 84 | "Zuniceratops", 85 | "Einiosaurus", 86 | "Dromaeosaurus", 87 | "Massospondylus", 88 | "Lesothosaurus", 89 | "Noasaurus", 90 | "Gasparinisaura", 91 | "Minmi", 92 | ] 93 | 94 | UNLOCK_SEED_ROADMAP_DAYS = {3: "corn", 7: "pumpkin"} 95 | 96 | 97 | class EventConstants: 98 | DEBUFF_STAMINA_LAZY_DAY = 2 99 | 100 | 101 | class FishingConstants: 102 | FISHES = ["salmon", "tuna", "golden_fish", "skyfish"] 103 | 104 | FISH_TYPES = { 105 | "salmon": Fish("Salmon", 40), 106 | "tuna": Fish("Tuna", 50), 107 | "golden_fish": Fish("Golden Fish", 100), 108 | "skyfish": Fish("Skyfish", 150), 109 | } 110 | 111 | STAMINA_TO_FISH = 2 112 | -------------------------------------------------------------------------------- /src/game/service/event_system.py: -------------------------------------------------------------------------------- 1 | import random 2 | from interfaces.game_system import IGameSystem 3 | from service.farm_system import FarmSystem 4 | from domain.player import Player 5 | from domain.fish import Fish 6 | from utils.constants import EventConstants, FishingConstants 7 | 8 | 9 | class EventSystem(IGameSystem): 10 | BASE_CHANCE_TO_EVENT = 0.4 11 | 12 | def __init__(self, farm: FarmSystem, player: Player): 13 | self.farm = farm 14 | self.player = player 15 | self.last_event_day = -1 16 | 17 | def update(self, current_day: int): 18 | if ( 19 | random.random() >= self.BASE_CHANCE_TO_EVENT 20 | or self.last_event_day == current_day 21 | ): 22 | return None 23 | 24 | self.last_event_day = current_day 25 | event = random.choice( 26 | [ 27 | self._storm_event, 28 | self._sunny_bonus_event, 29 | self._found_money_event, 30 | self._found_energy_event, 31 | self._fish_rain_event, 32 | self._plague_event, 33 | self._spirit_farmer_event, 34 | self._lazy_day_event, 35 | self._starry_night_event, 36 | self._inflated_market_event, 37 | self._night_robbery_event, 38 | self._perfect_fishing_day_event, 39 | self._rich_farmer_patron_event, 40 | self._sugar_daddy_marriage_event, 41 | ] 42 | ) 43 | return event() 44 | 45 | def _rich_farmer_patron_event(self): 46 | amount = 500 47 | self.player.earn_money(amount) 48 | return "Your charm paid off. A rich old farmer who just loves your crops appears. 💖 (+$500)" 49 | 50 | def _sugar_daddy_marriage_event(self): 51 | amount = 3000 52 | self.player.earn_money(amount) 53 | return "Farm life is tough… unless you marry rich! 💍 (+$3,000)" 54 | 55 | def _storm_event(self): 56 | return self.farm.damage_random_crop() 57 | 58 | def _sunny_bonus_event(self): 59 | return self.farm.apply_growth_bonus(20) 60 | 61 | def _found_money_event(self): 62 | amount = random.randint(10, 50) 63 | self.player.earn_money(amount) 64 | return f"You found money on the ground! (+${amount})" 65 | 66 | def _found_energy_event(self): 67 | self.player.restore_stamina(1) 68 | return "You found an energy drink! (+1 heart)" 69 | 70 | def _fish_rain_event(self): 71 | if not hasattr(self, "game") or not hasattr(self.game, "fishing_system"): 72 | return 73 | 74 | skyfish: Fish = FishingConstants.FISH_TYPES["skyfish"] 75 | 76 | self.game.fishing_system.caught_fish.append(skyfish) 77 | return f"A mysterious rain dropped a {skyfish.name} into your bucket! (+${skyfish.price})" 78 | 79 | def _plague_event(self): 80 | if any(self.farm.damage_random_crop() for _ in range(2)): 81 | return "A mysterious plague destroyed some crops!" 82 | 83 | def _spirit_farmer_event(self): 84 | if hasattr(self, "game") and hasattr(self.game, "crop_system"): 85 | if "lazy_ghost" not in self.game.crop_system.unlocked_crops: 86 | self.game.crop_system.unlocked_crops.append("lazy_ghost") 87 | return "A benevolent spirit gifted you a Lazy Ghost Seed!" 88 | 89 | def _lazy_day_event(self): 90 | if self.player.max_stamina <= 1: 91 | return 92 | 93 | self.player.max_stamina -= EventConstants.DEBUFF_STAMINA_LAZY_DAY 94 | self.player.stamina = min(self.player.stamina, self.player.max_stamina) 95 | 96 | if hasattr(self, "game"): 97 | self.game.lazy_day_active = True 98 | 99 | return f"You feel extremely lazy today... (-{EventConstants.DEBUFF_STAMINA_LAZY_DAY} Max Hearts)" 100 | 101 | def _starry_night_event(self): 102 | return self.farm.apply_growth_bonus(100) 103 | 104 | def _inflated_market_event(self): 105 | if hasattr(self, "game"): 106 | self.game.market_inflated = True 107 | return "Prices have doubled today! (Inflated Market)" 108 | 109 | def _night_robbery_event(self): 110 | stolen = min(100, self.player.money) 111 | self.player.spend_money(stolen) 112 | return f"Thieves stole ${stolen} from your farm during the night!" 113 | 114 | def _perfect_fishing_day_event(self): 115 | if hasattr(self, "game"): 116 | self.game.fishing_bonus = True 117 | return "The fish are biting! (+50% fish value today!)" 118 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TerminalFarm 7 | 8 | 9 | 10 |
11 |
12 |
13 | 01 14 | Morning | 🌸 Spring 15 |
16 |
17 |
18 |
$150.000
19 |
20 |
21 | 22 |
23 |
24 | 25 |
26 |
27 |
Slot 01
28 |
29 |
30 |
31 |
Slot 02
32 |
33 |
34 |
35 |
Slot 03
36 |
37 |
38 |
39 |
Slot 04
40 |
41 |
42 |
43 |
Slot 05
44 |
45 |
46 |
47 |
Slot 06
48 |
49 |
50 |
51 |
Slot 07
52 |
53 |
54 |
55 |
Slot 08
56 |
57 |
58 |
59 |
Slot 09
60 |
61 |
62 |
63 | 64 | 111 |
112 | 113 | 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /src/game/service/merchant_system.py: -------------------------------------------------------------------------------- 1 | from domain.player import Player 2 | from domain.crop import Crop 3 | from service.crop_system import CropSystem 4 | from typing import Optional 5 | 6 | 7 | class MerchantSystem: 8 | def __init__(self, crop_system: CropSystem, player: Player): 9 | self.crop_system = crop_system 10 | self.player = player 11 | self.fishing_unlocked = False 12 | 13 | if "Skyfish" not in [fish["name"] for fish in getattr(self, "fish_types", [])]: 14 | pass 15 | if "lazy_ghost" not in self.crop_system.available_crops: 16 | self.crop_system.available_crops["lazy_ghost"] = Crop( 17 | "lazy_ghost", 0, 30, 100, "white", 0 18 | ) 19 | 20 | self.inventory = { 21 | "seeds": { 22 | "eggplant_seed": {"crop": "eggplant", "price": 80}, 23 | "blueberry_seed": {"crop": "blueberry", "price": 120}, 24 | }, 25 | "items": { 26 | "farmdex_scanner": { 27 | "price": 300, 28 | "effect": "unlock_farmdex", 29 | "narrative": True, 30 | }, 31 | "fishing_rod": {"price": 5000, "unlocks": "fishing"}, 32 | "golden_hat": {"price": 6666, "effect": "cosmetic", "narrative": True}, 33 | "lucky_egg": {"price": 5000, "effect": "increase_event_chance"}, 34 | "balatro_card": {"price": 8888, "effect": "increase_max_stamina"}, 35 | "lantern": {"price": 2000, "effect": "unlock_night_work"}, 36 | "sleep_pills": { 37 | "price": 3000, 38 | "effect": "unlock_anytime_sleep", 39 | "narrative": True, 40 | }, 41 | }, 42 | } 43 | 44 | def is_available(self, part_of_day: str) -> bool: 45 | return part_of_day == "morning" 46 | 47 | def buy_seed(self, seed_key: str) -> Optional[str]: 48 | if seed_key not in self.inventory["seeds"]: 49 | return "Invalid seed." 50 | 51 | seed = self.inventory["seeds"][seed_key] 52 | price = seed["price"] 53 | if hasattr(self.player, "game") and getattr( 54 | self.player.game, "market_inflated", False 55 | ): 56 | price *= 2 57 | if not self.player.can_afford(price): 58 | return "Not enough money." 59 | 60 | self.player.spend_money(price) 61 | result = self.crop_system.unlock_crop(seed["crop"]) 62 | return result or f"{seed['crop'].capitalize()} is already unlocked." 63 | 64 | def buy_item(self, item_key: str) -> Optional[str]: 65 | if item_key not in self.inventory["items"]: 66 | return "Invalid item." 67 | 68 | item = self.inventory["items"][item_key] 69 | 70 | if item.get("unlocks") == "fishing" and self.fishing_unlocked: 71 | return "You already own this item." 72 | if ( 73 | item.get("effect") == "increase_event_chance" 74 | and hasattr(self.player, "event_bonus") 75 | and self.player.event_bonus == "lucky_egg" 76 | ): 77 | return "You already own this item." 78 | if item.get("effect") == "increase_max_stamina" and self.player.max_stamina > 5: 79 | return "You already own this item." 80 | if ( 81 | item.get("effect") == "cosmetic" 82 | and hasattr(self.player, "bought_hat") 83 | and self.player.bought_hat 84 | ): 85 | return "You already own this item." 86 | if item.get("effect") == "unlock_night_work" and getattr( 87 | self.player, "has_lantern", False 88 | ): 89 | return "You already own this item." 90 | if item.get("effect") == "unlock_farmdex" and getattr( 91 | self.player, "has_farmdex", False 92 | ): 93 | return "You already own this item." 94 | if item.get("effect") == "unlock_anytime_sleep" and getattr( 95 | self.player, "can_sleep_anytime", False 96 | ): 97 | return "You already own this item." 98 | 99 | if not self.player.can_afford(item["price"]): 100 | return "Not enough money." 101 | 102 | self.player.spend_money(item["price"]) 103 | if item.get("unlocks") == "fishing": 104 | self.fishing_unlocked = True 105 | return "You bought a fishing rod! Fishing is now available." 106 | elif item.get("effect") == "increase_event_chance": 107 | self.player.event_bonus = "lucky_egg" 108 | return "You feel luckier already... (+Event Chance)" 109 | elif item.get("effect") == "increase_max_stamina": 110 | self.player.max_stamina += 4 111 | self.player.stamina = self.player.max_stamina 112 | return "Your soul feels stronger... (+4 Max Stamina)" 113 | elif item.get("effect") == "cosmetic": 114 | self.player.bought_hat = True 115 | return "Cosmetic item? In a CLI game? Bro... you deserved to lose that money. I'm sorry." 116 | elif item.get("effect") == "unlock_night_work": 117 | self.player.has_lantern = True 118 | return "You bought a lantern! Now you can work through the night." 119 | elif item.get("effect") == "unlock_farmdex": 120 | self.player.has_farmdex = True 121 | return "Every two days, you have a 75% chance to discover a buried fossil! Help the local museum build the greatest dinosaur collection in history!" 122 | elif item.get("effect") == "unlock_anytime_sleep": 123 | self.player.can_sleep_anytime = True 124 | return "You bought Sleep Pills! Now you can sleep anytime to skip the day." 125 | return "Item purchased." 126 | -------------------------------------------------------------------------------- /web/assets/half_h.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/game/service/game_state.py: -------------------------------------------------------------------------------- 1 | import random 2 | import json 3 | import os 4 | from interfaces.serializable import ISerializable 5 | from domain.player import Player 6 | from service.farm_system import FarmSystem 7 | from service.crop_system import CropSystem 8 | from service.weather_system import WeatherSystem 9 | from service.time_system import TimeSystem 10 | from service.event_system import EventSystem 11 | from service.merchant_system import MerchantSystem 12 | from service.fishing_system import FishingSystem 13 | from service.daycycle_system import DayCycleSystem 14 | from typing import Optional, Any, Tuple 15 | from datetime import datetime 16 | from utils.constants import GameStateConstants, EventConstants 17 | 18 | 19 | class GameState(ISerializable): 20 | SAVE_FILE = "terminal_farmer_save.json" 21 | 22 | def __init__(self): 23 | self.player = Player() 24 | self.farm = FarmSystem() 25 | self.farm.game = self 26 | self.crop_system = CropSystem() 27 | self.weather_system = WeatherSystem() 28 | self.time_system = TimeSystem() 29 | self.event_system = EventSystem(self.farm, self.player) 30 | self.event_system.game = self 31 | self.day_cycle_system = DayCycleSystem(self.time_system) 32 | self.merchant_system = MerchantSystem(self.crop_system, self.player) 33 | self.fishing_system = FishingSystem(self.player) 34 | self.fishing_system.game = self 35 | self.lazy_day_active = False 36 | 37 | def next_day(self) -> Tuple[bool, Optional[str]]: 38 | """Advance to next day, returns (success, event_message)""" 39 | if not self.player.has_stamina(1.0): 40 | return False, None 41 | 42 | self.player.use_stamina(1.0) 43 | self.time_system.update() 44 | self.weather_system.update() 45 | self.day_cycle_system = DayCycleSystem(self.time_system) 46 | self.__unlock_fossil() 47 | 48 | unlock_message = self.__unlock_seed_roadmap() 49 | 50 | self.__reset_day_bonus() 51 | 52 | event_message = self.event_system.update(self.time_system.day) 53 | 54 | return True, unlock_message or event_message 55 | 56 | def __unlock_fossil(self) -> None: 57 | if self.player.has_farmdex and self.time_system.day % 2 == 0: 58 | if random.random() < 0.75 and len(self.player.fossils_found) < len( 59 | GameStateConstants.FOSSILS 60 | ): 61 | undiscovered = [ 62 | f 63 | for f in GameStateConstants.FOSSILS 64 | if f not in self.player.fossils_found 65 | ] 66 | if undiscovered: 67 | found = random.choice(undiscovered) 68 | self.player.fossils_found.append(found) 69 | return True, f"NEW FOSSIL DISCOVERED: {found}!" 70 | 71 | def __unlock_seed_roadmap(self) -> str: 72 | crop = GameStateConstants.UNLOCK_SEED_ROADMAP_DAYS.get(self.time_system.day) 73 | 74 | if crop and crop not in self.crop_system.unlocked_crops: 75 | return self.crop_system.unlock_crop(crop) 76 | 77 | def __reset_day_bonus(self) -> None: 78 | self.market_inflated = False 79 | self.fishing_bonus = False 80 | self.lazy_day_active = False 81 | 82 | if self.lazy_day_active: 83 | self.player.max_stamina += EventConstants.DEBUFF_STAMINA_LAZY_DAY 84 | if self.player.stamina > self.player.max_stamina: 85 | self.player.stamina = self.player.max_stamina 86 | 87 | def save(self) -> bool: 88 | try: 89 | with open(self.SAVE_FILE, "w") as f: 90 | json.dump(self.to_dict(), f) 91 | return True 92 | except Exception as e: 93 | print(f"Error saving game: {e}") 94 | return False 95 | 96 | def load(self) -> bool: 97 | try: 98 | if not os.path.exists(self.SAVE_FILE): 99 | return False 100 | 101 | with open(self.SAVE_FILE, "r") as f: 102 | data = json.load(f) 103 | self.from_dict(data, fallback=True) 104 | 105 | time_passed = datetime.now() - self.player.last_sleep_time 106 | hours_passed = time_passed.total_seconds() / 3600 107 | stamina_to_restore = min( 108 | int(hours_passed / 2), self.player.max_stamina - self.player.stamina 109 | ) 110 | if stamina_to_restore > 0: 111 | self.player.restore_stamina(stamina_to_restore) 112 | return True 113 | 114 | except Exception as e: 115 | print(f"Error loading game: {e}") 116 | return False 117 | 118 | def new_game(self): 119 | self.__init__() 120 | 121 | def to_dict(self) -> dict[str, Any]: 122 | return { 123 | "player": self.player.to_dict(), 124 | "farm": self.farm.to_dict(), 125 | "crop_system": self.crop_system.to_dict(), 126 | "weather_system": self.weather_system.to_dict(), 127 | "time_system": self.time_system.to_dict(), 128 | "day_cycle_system": self.day_cycle_system.to_dict(), 129 | "merchant": {"fishing_unlocked": self.merchant_system.fishing_unlocked}, 130 | } 131 | 132 | def from_dict(self, data: dict[str, Any], fallback: bool = False): 133 | self.player = Player.from_dict(data["player"]) 134 | self.farm = FarmSystem.from_dict(data["farm"]) 135 | self.farm.game = self 136 | self.crop_system = CropSystem.from_dict(data["crop_system"]) 137 | self.weather_system = WeatherSystem.from_dict(data["weather_system"]) 138 | self.time_system = TimeSystem.from_dict(data["time_system"]) 139 | if "day_cycle_system" in data: 140 | self.day_cycle_system = DayCycleSystem.from_dict(data["day_cycle_system"]) 141 | elif fallback: 142 | self.day_cycle_system = DayCycleSystem(self.time_system) 143 | self.event_system = EventSystem(self.farm, self.player) 144 | self.event_system.game = self 145 | self.merchant_system = MerchantSystem(self.crop_system, self.player) 146 | self.fishing_system = FishingSystem(self.player) 147 | self.fishing_system.game = self 148 | if "merchant" in data and data["merchant"].get("fishing_unlocked"): 149 | self.merchant_system.fishing_unlocked = True 150 | -------------------------------------------------------------------------------- /web/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --accent: #4be08f; 3 | --soil: #6B5F5F; 4 | --tile: #E8E5E0; 5 | --btn-bg: #f0f0f0; 6 | --btn-color: black; 7 | --cost-color: #FF0000; 8 | --profit-color: #53AD00; 9 | --stamina-color: #D7113C; 10 | --slot-shadow: 0px 4px 0px #bdafa4, 0px 8px 12px 2px rgba(0, 0, 0, 0.25); 11 | --crop-status-size: 20px; 12 | --crop-status-weight: 600; 13 | } 14 | * { 15 | margin: 0; 16 | padding: 0; 17 | box-sizing: border-box; 18 | } 19 | 20 | body { 21 | font-family: sans-serif; 22 | background-color: #e5f0d6; 23 | color: #333; 24 | overflow: hidden; 25 | height: 100dvh; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | margin: 0; 30 | background: linear-gradient(135deg, #709247, #9CBF59); 31 | background-repeat: no-repeat; 32 | background-attachment: fixed; 33 | } 34 | 35 | .container { 36 | width: 100%; 37 | max-width: 500px; 38 | min-height: 100dvh; 39 | display: flex; 40 | flex-direction: column; 41 | padding: 2rem 1rem 1rem 1rem; 42 | margin: 0 auto; 43 | overflow: hidden; 44 | gap: 1rem; 45 | height: 100dvh; 46 | } 47 | 48 | .top-block { 49 | flex: 0 0 80px; 50 | display: flex; 51 | flex-direction: column; 52 | gap: 0.5rem; 53 | margin-top: 1rem; 54 | } 55 | 56 | .date-info { 57 | background-image: url('assets/date_label.svg'); 58 | background-size: contain; 59 | background-repeat: no-repeat; 60 | background-position: center; 61 | border: none; 62 | padding: 1rem 1.5rem; 63 | display: flex; 64 | align-items: center; 65 | gap: 0.75rem; 66 | width: fit-content; 67 | font-family: 'Poppins', sans-serif; 68 | font-weight: 600; 69 | font-size: 1rem; 70 | } 71 | 72 | .date-info .day { 73 | font-size: 1.4rem; 74 | font-weight: 700; 75 | color: #4e443d; 76 | } 77 | 78 | .date-info .time-season { 79 | font-size: 1rem; 80 | color: #6B5F5F; 81 | } 82 | 83 | .status-info { 84 | display: flex; 85 | justify-content: space-between; 86 | align-items: center; 87 | } 88 | 89 | .hearts { 90 | font-size: 1.2rem; 91 | } 92 | 93 | .money { 94 | background-color: white; 95 | padding: 0.4rem 0.8rem; 96 | border-radius: 1rem; 97 | font-weight: bold; 98 | } 99 | 100 | .middle-block .grid { 101 | display: grid; 102 | grid-template-columns: repeat(3, 1fr); 103 | gap: 18px; 104 | justify-items: center; 105 | justify-content: center; 106 | align-content: center; 107 | align-items: center; 108 | } 109 | 110 | .middle-block { 111 | flex: 1 1 auto; 112 | width: 100%; 113 | display: flex; 114 | justify-content: center; 115 | padding: 0; 116 | min-height: 300px; 117 | max-height: 500px; 118 | } 119 | 120 | .slot { 121 | width: 132px; 122 | height: 132px; 123 | background-color: var(--slot-bg, var(--tile)); 124 | border-radius: 10px; 125 | border: 5px solid var(--slot-border, white); 126 | box-shadow: var(--slot-shadow); 127 | display: flex; 128 | align-items: center; 129 | justify-content: center; 130 | position: relative; 131 | box-sizing: border-box; 132 | flex-direction: column; 133 | } 134 | 135 | 136 | .slot::before { 137 | content: ""; 138 | position: absolute; 139 | top: 10px; 140 | left: 10px; 141 | right: 10px; 142 | bottom: 10px; 143 | border: 1px dashed #BEB09E; 144 | border-radius: 6px; 145 | pointer-events: none; 146 | } 147 | 148 | .slot .crop-status { 149 | font-family: 'Poppins', sans-serif; 150 | font-weight: var(--crop-status-weight); 151 | font-size: var(--crop-status-size); 152 | color: var(--slot-status-color, #5e5551); 153 | text-align: center; 154 | margin-top: auto; 155 | margin-bottom: 12px; 156 | } 157 | 158 | .slot[data-state="empty"] { 159 | --slot-bg: var(--tile); 160 | } 161 | 162 | .slot[data-state="growing"] { 163 | --slot-bg: #f4e9c4; 164 | --slot-image: url('assets/sprout.png'); 165 | } 166 | 167 | .slot[data-state="ready"] { 168 | --slot-bg: #d4f7d4; 169 | --slot-border: white; 170 | --slot-image: url('assets/crop.png'); 171 | --slot-status-color: #3a7a3a; 172 | --slot-status-weight: bold; 173 | position: relative; 174 | } 175 | 176 | .slot[data-state="ready"]::after { 177 | content: ""; 178 | position: absolute; 179 | top: -10px; 180 | right: -10px; 181 | width: 36px; 182 | height: 36px; 183 | background-image: url('assets/ready.svg'); 184 | background-size: contain; 185 | background-repeat: no-repeat; 186 | background-position: center; 187 | z-index: 2; 188 | } 189 | 190 | .bottom-block { 191 | flex: 0 0 120px; 192 | display: flex; 193 | flex-direction: column; 194 | gap: 0.75rem; 195 | } 196 | 197 | .actions, 198 | .events, 199 | .reset { 200 | display: flex; 201 | flex-wrap: wrap; 202 | gap: 0.5rem; 203 | justify-content: center; 204 | } 205 | 206 | button { 207 | padding: 0.5rem 1rem; 208 | border: none; 209 | border-radius: 1rem; 210 | font-weight: bold; 211 | background-color: var(--btn-bg); 212 | color: var(--btn-color); 213 | cursor: pointer; 214 | } 215 | 216 | button:focus { 217 | outline: 2px dashed #333; 218 | outline-offset: 4px; 219 | } 220 | 221 | .primary { 222 | --btn-bg: var(--accent); 223 | --btn-color: white; 224 | } 225 | 226 | .event-btn { 227 | --btn-bg: gold; 228 | } 229 | 230 | .reset-btn { 231 | --btn-bg: red; 232 | --btn-color: white; 233 | } 234 | 235 | @media (max-width: 600px) { 236 | :root { 237 | --slot-shadow: 0px 2px 0px #bdafa4, 0px 2px 4px rgba(0, 0, 0, 0.15); 238 | --crop-status-size: 14px; 239 | } 240 | .slot { 241 | width: 104px; 242 | height: 104px; 243 | border-width: 3px; 244 | } 245 | 246 | .slot::before { 247 | top: 6px; 248 | left: 6px; 249 | right: 6px; 250 | bottom: 6px; 251 | } 252 | 253 | .slot .crop-status { 254 | margin-top: auto; 255 | margin-bottom: 12px; 256 | } 257 | 258 | .middle-block .grid { 259 | grid-template-columns: repeat(3, 1fr); 260 | } 261 | 262 | .container { 263 | max-width: 100%; 264 | padding: 2rem 1rem 1rem 1rem; 265 | display: flex; 266 | flex-direction: column; 267 | gap: 1rem; 268 | height: auto; 269 | min-height: 100dvh; 270 | } 271 | .plant-scroll-wrapper .action { 272 | padding: 0.5rem 1.25rem; 273 | } 274 | 275 | .crop-left { 276 | margin-left: 16px; 277 | } 278 | 279 | .crop-right { 280 | margin-right: 16px; 281 | } 282 | .middle-block { 283 | flex: 1 1 auto; 284 | height: auto; 285 | } 286 | 287 | .top-block { 288 | flex: 0 0 auto; 289 | } 290 | 291 | .bottom-block { 292 | flex: 0 0 auto; 293 | } 294 | } 295 | body::before { 296 | content: ""; 297 | position: fixed; 298 | top: 0; 299 | left: 0; 300 | width: 200%; 301 | height: 200%; 302 | background-image: url('assets/pattern.png'); 303 | background-repeat: repeat; 304 | background-size: 120px 120px; 305 | opacity: 0.03; 306 | animation: diagonalScroll 40s linear infinite; 307 | z-index: -1; 308 | will-change: transform; 309 | image-rendering: optimizeSpeed; 310 | pointer-events: none; 311 | } 312 | 313 | @keyframes diagonalScroll { 314 | 0% { 315 | transform: translate(0, 0); 316 | } 317 | 100% { 318 | transform: translate(-120px, -120px); 319 | } 320 | } 321 | .actions-block h2 { 322 | font-size: 1.2rem; 323 | font-weight: bold; 324 | font-style: italic; 325 | margin-bottom: 0.5rem; 326 | font-family: 'Poppins', sans-serif; 327 | } 328 | 329 | .actions-grid { 330 | display: flex; 331 | flex-wrap: wrap; 332 | gap: 0.75rem; 333 | justify-content: flex-start; 334 | } 335 | 336 | .action { 337 | font-family: 'Poppins', sans-serif; 338 | font-weight: 600; 339 | background-color: var(--tile); 340 | border: 2px solid var(--soil); 341 | color: var(--soil); 342 | padding: 0.5rem 1rem; 343 | border-radius: 1rem; 344 | box-shadow: inset 0 0 0 1px var(--soil); 345 | font-size: 0.9rem; 346 | cursor: pointer; 347 | } 348 | 349 | .action.primary { 350 | background-color: var(--accent); 351 | color: white; 352 | border: 2px solid var(--accent); 353 | } 354 | 355 | .hidden { 356 | display: none !important; 357 | } 358 | 359 | .plant-menu { 360 | height: calc(2 * 2.5rem + 0.5rem); 361 | position: relative; 362 | overflow: hidden; 363 | } 364 | 365 | .plant-grid { 366 | display: flex; 367 | flex-wrap: wrap; 368 | gap: 0.75rem; 369 | justify-content: center; 370 | } 371 | 372 | .back-button { 373 | position: fixed; 374 | bottom: 1rem; 375 | right: 1rem; 376 | padding: 0.6rem 1rem; 377 | font-size: 1rem; 378 | border-radius: 2rem; 379 | background-color: var(--soil); 380 | color: white; 381 | font-family: 'Poppins', sans-serif; 382 | font-weight: 600; 383 | border: none; 384 | cursor: pointer; 385 | box-shadow: 0 4px 8px rgba(0,0,0,0.2); 386 | } 387 | .plant-scroll-wrapper { 388 | height: 100%; 389 | padding-bottom: 1rem; 390 | position: relative; 391 | display: flex; 392 | flex-direction: column; 393 | gap: 0.5rem; 394 | justify-content: flex-start; 395 | align-items: flex-start; 396 | width: 100%; 397 | align-items: stretch; 398 | scrollbar-width: none; /* Firefox */ 399 | -ms-overflow-style: none; /* IE and Edge */ 400 | scroll-snap-type: y mandatory; 401 | scroll-padding-top: 0.5rem; 402 | scroll-behavior: smooth; 403 | overflow-y: scroll; 404 | } 405 | 406 | .plant-scroll-wrapper::-webkit-scrollbar { 407 | display: none; 408 | } 409 | 410 | 411 | 412 | @keyframes blink { 413 | 0%, 100% { opacity: 1; } 414 | 50% { opacity: 0; } 415 | } 416 | 417 | .plant-scroll-wrapper .action { 418 | display: flex; 419 | justify-content: space-between; 420 | align-items: center; 421 | width: 100%; 422 | padding: 0.75rem 1.5rem; 423 | position: relative; 424 | scroll-snap-align: start; 425 | } 426 | 427 | .crop-left { 428 | font-family: 'Poppins', sans-serif; 429 | font-weight: 600; 430 | font-size: 1rem; 431 | color: var(--soil); 432 | margin-left: 27px; 433 | } 434 | 435 | .crop-right { 436 | display: flex; 437 | align-items: center; 438 | gap: 0.75rem; 439 | margin-right: 27px; 440 | font-family: 'Poppins', sans-serif; 441 | font-size: 0.9rem; 442 | } 443 | 444 | .crop-cost { 445 | color: var(--cost-color); 446 | font-weight: bold; 447 | } 448 | 449 | .crop-profit { 450 | color: var(--profit-color); 451 | font-weight: bold; 452 | } 453 | 454 | .crop-time { 455 | color: var(--soil); 456 | } 457 | 458 | .crop-stamina { 459 | color: var(--stamina-color); 460 | display: flex; 461 | align-items: center; 462 | gap: 0.25rem; 463 | } 464 | 465 | .crop-stamina img { 466 | width: 16px; 467 | height: 16px; 468 | } 469 | .plant-menu::after { 470 | content: "▼"; 471 | position: absolute; 472 | bottom: 0.5rem; 473 | right: 0.75rem; 474 | font-size: 1.2rem; 475 | color: var(--soil); 476 | pointer-events: none; 477 | animation: blink 1.5s infinite; 478 | } 479 | .hearts { 480 | display: grid; 481 | grid-template-columns: repeat(8, 1fr); 482 | gap: 0.25rem; 483 | width: fit-content; 484 | } 485 | 486 | .hearts img { 487 | width: 16px; 488 | height: 16px; 489 | } 490 | .date-info span { 491 | transform: rotate(-2deg); 492 | transform-origin: bottom left; 493 | display: inline-block; 494 | } 495 | .crop-image { 496 | background-image: var(--slot-image, none); 497 | } -------------------------------------------------------------------------------- /src/game/service/tui_system.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from service.game_state import GameState 3 | import time 4 | import sys 5 | from utils.constants import TUIConstants 6 | 7 | 8 | class TerminalUI: 9 | MENU_COOLDOWN_TIME = 2.6 10 | SPACE_BETWEEN_CROP_INFO = " " * 3 11 | 12 | def display_status(self): 13 | weather = self.game.weather_system.get_weather() 14 | weather_icon = TUIConstants.WEATHER_ICONS.get(weather, "") 15 | money_text = f"💰 Money: ${self.game.player.money}" 16 | weather_text = f"Weather: {weather_icon} {weather.capitalize()}" 17 | 18 | header_width = self.last_box_width if hasattr(self, "last_box_width") else 50 19 | content = f"{money_text} {weather_text}" 20 | 21 | print(self.color_text("═" * header_width, "bright_cyan")) 22 | print(content) 23 | print(self.color_text("═" * header_width, "bright_cyan")) 24 | 25 | def display_farm(self): 26 | self.clear_screen() 27 | self.display_header() 28 | self.display_status() 29 | 30 | print(f"{self.color_text('🌱 Farm Layout:', 'bright_green')}\n") 31 | 32 | for i in range(0, 9, 3): 33 | row_lines = ["", "", ""] 34 | for j in range(3): 35 | plot_idx = i + j 36 | crop, progress = self.game.farm.get_plot_status(plot_idx) 37 | 38 | if crop: 39 | bg_color = "green" if progress >= 1.0 else "yellow_pastel" 40 | fg_color = "white" if progress >= 1.0 else "gray" 41 | else: 42 | bg_color = "orange" 43 | fg_color = "white" 44 | slot_text = str(plot_idx + 1).center(9) 45 | content_text = crop.name[:7].center(9) if crop else "Empty".center(9) 46 | spacer = " " 47 | 48 | row_lines[0] += ( 49 | self.bg_color_text(slot_text, fg_color, bg_color) + spacer 50 | ) 51 | row_lines[1] += ( 52 | self.bg_color_text(content_text, fg_color, bg_color) + spacer 53 | ) 54 | row_lines[2] += self.bg_color_text(" " * 9, fg_color, bg_color) + spacer 55 | 56 | for line in row_lines: 57 | print(line) 58 | print() 59 | 60 | def bg_color_text(self, text: str, fg_color: str, bg_color: str) -> str: 61 | fg = TUIConstants.COLORS.get(fg_color, "") 62 | bg = TUIConstants.BG_COLORS.get(bg_color, "") 63 | return f"{bg}{fg}{text}{TUIConstants.COLORS['reset']}" 64 | 65 | def __init__(self, game_state: GameState): 66 | self.game = game_state 67 | 68 | def clear_screen(self): 69 | print("\033[H\033[J") 70 | 71 | def color_text(self, text: str, color: str) -> str: 72 | return ( 73 | f"{TUIConstants.COLORS.get(color, '')}{text}{TUIConstants.COLORS['reset']}" 74 | ) 75 | 76 | def strip_ansi(self, text: str) -> str: 77 | import re 78 | 79 | ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") 80 | return ansi_escape.sub("", text) 81 | 82 | def display_stamina(self, stamina: float, max_stamina: int) -> str: 83 | full_hearts = int(stamina) 84 | half_heart = (stamina - full_hearts) >= 0.5 85 | empty_hearts = max_stamina - full_hearts - (1 if half_heart else 0) 86 | 87 | hearts = [] 88 | hearts.extend([self.color_text("♥", "heart_red")] * full_hearts) 89 | if half_heart: 90 | hearts.append(self.color_text("♥", "pink")) 91 | hearts.extend([self.color_text("♡", "gray")] * empty_hearts) 92 | 93 | return " ".join(hearts) 94 | 95 | def get_greeting(self) -> str: 96 | hour = datetime.now().hour 97 | if 5 <= hour < 12: 98 | return "Good morning" 99 | elif 12 <= hour < 17: 100 | return "Good afternoon" 101 | elif 17 <= hour < 21: 102 | return "Good evening" 103 | else: 104 | return "Good night" 105 | 106 | def get_season_icon(self) -> str: 107 | return TUIConstants.SEASON_ICONS.get( 108 | self.game.day_cycle_system.get_season(), "" 109 | ) 110 | 111 | def display_action_message( 112 | self, 113 | cancellable: bool = False, 114 | message: str = "Choose action", 115 | cancel_message: str = "(0 to cancel): ", 116 | ) -> str: 117 | cancel_text = cancel_message if cancellable else "" 118 | return f"\n{self.color_text(message, 'bright_cyan')} {cancel_text}" 119 | 120 | def display_header(self): 121 | message = self.game.day_cycle_system.update() 122 | if message: 123 | print(self.color_text(message, "bright_cyan")) 124 | import getpass 125 | 126 | username = getpass.getuser() 127 | greeting = self.get_greeting() 128 | stamina_display = self.display_stamina( 129 | self.game.player.stamina, self.game.player.max_stamina 130 | ) 131 | season = self.game.day_cycle_system.get_season().capitalize() 132 | current_part = self.game.day_cycle_system.get_current_part().capitalize() 133 | season_icon = self.get_season_icon() 134 | day = self.game.time_system.day 135 | 136 | TITLE_LINE_LEFT = "🌱 TERMINAL FARM" 137 | TITLE_LINE_RIGHT = f"Day {day} ({current_part}) {season_icon} {season}" 138 | GREETING_LINE = f"{greeting}, {username}!" 139 | 140 | content_width = ( 141 | max( 142 | len(TITLE_LINE_LEFT) + len(TITLE_LINE_RIGHT) + 2, 143 | len(GREETING_LINE), 144 | len(self.strip_ansi(stamina_display)) + len("Stamina: "), 145 | ) 146 | + 6 147 | ) 148 | BOX_WIDTH = content_width 149 | BOX_BORDER_HORIZONTAL = "═" * BOX_WIDTH 150 | 151 | spacing = BOX_WIDTH - len(TITLE_LINE_LEFT) - len(TITLE_LINE_RIGHT) - 2 152 | TITLE_LINE = f"{TITLE_LINE_LEFT}{' ' * spacing}{TITLE_LINE_RIGHT}" 153 | 154 | centered_title = TITLE_LINE 155 | title_line = f"{self.color_text('║', 'bright_cyan')}{self.color_text(centered_title.ljust(BOX_WIDTH - 2), 'bright_green')}{self.color_text('║', 'bright_cyan')}" 156 | greeting_line = f"{self.color_text('║', 'bright_cyan')} {self.color_text(GREETING_LINE.ljust(BOX_WIDTH - 4), 'green')} {self.color_text('║', 'bright_cyan')}" 157 | stamina_text = f"Stamina: {stamina_display}" 158 | padding = (BOX_WIDTH - 4) - len(self.strip_ansi(stamina_text)) 159 | stamina_line = f"{self.color_text('║', 'bright_cyan')} {stamina_text}{' ' * padding} {self.color_text('║', 'bright_cyan')}" 160 | 161 | print(self.color_text(f"╔{BOX_BORDER_HORIZONTAL}╗", "bright_cyan")) 162 | print(title_line) 163 | print(self.color_text(f"╠{BOX_BORDER_HORIZONTAL}╣", "bright_cyan")) 164 | print(greeting_line) 165 | print(stamina_line) 166 | self.last_box_width = BOX_WIDTH 167 | print(self.color_text(f"╚{BOX_BORDER_HORIZONTAL}╝", "bright_cyan")) 168 | 169 | def plant_crop_menu(self): 170 | self.display_farm() 171 | unlocked = self.game.crop_system.get_unlocked_crops() 172 | self._display_crop_menu() 173 | 174 | choice = input( 175 | self.display_action_message( 176 | message="Choose crop to plant", cancellable=True 177 | ) 178 | ) 179 | if choice == "0": 180 | return 181 | 182 | try: 183 | crop = unlocked[int(choice) - 1] 184 | except (ValueError, IndexError): 185 | input(f"{self.color_text('Invalid choice!', 'red')} Press Enter...") 186 | return 187 | 188 | if not self.game.player.has_stamina(crop.stamina_cost): 189 | input(f"{self.color_text('Not enough stamina!', 'red')} Press Enter...") 190 | return 191 | 192 | if not self.game.player.can_afford(crop.cost): 193 | input(f"{self.color_text('Not enough money!', 'red')} Press Enter...") 194 | return 195 | 196 | print(f"\n{self.color_text('Farm Layout:', 'bright_green')}") 197 | for i in range(0, 9, 3): 198 | print(f"{self.color_text(f'{i + 1}-{i + 3}', 'cyan')} ", end="") 199 | print("\n") 200 | 201 | try: 202 | plot = ( 203 | int(input(f"{self.color_text('Choose plot', 'bright_cyan')} (1-9): ")) 204 | - 1 205 | ) 206 | if plot < 0 or plot > 8 or not self.game.farm.plots[plot].is_empty: 207 | raise ValueError 208 | except ValueError: 209 | input( 210 | f"{self.color_text('Invalid or occupied plot!', 'red')} Press Enter..." 211 | ) 212 | return 213 | 214 | self.game.player.spend_money(crop.cost) 215 | self.game.player.use_stamina(crop.stamina_cost) 216 | self.game.farm.plant_crop(plot, crop) 217 | print( 218 | f"\n{self.color_text(f'Planted {crop.name} in plot {plot + 1}!', 'green')}" 219 | ) 220 | time.sleep(self.MENU_COOLDOWN_TIME) 221 | 222 | def _display_crop_menu(self): 223 | unlocked = self.game.crop_system.get_unlocked_crops() 224 | max_name = max( 225 | len(c.name.replace(" [Rare]", "").replace(" [rare]", "")) for c in unlocked 226 | ) 227 | max_cost = max(len(f"${c.cost}") for c in unlocked) 228 | max_value = max(len(f"${c.value}") for c in unlocked) 229 | max_stamina = max(len(f"{c.stamina_cost} ♥") for c in unlocked) 230 | max_time = max(len(f"{c.growth_time}s") for c in unlocked) 231 | 232 | print(self.color_text("Available Crops:", "bright_blue")) 233 | for i, c in enumerate(unlocked, 1): 234 | name = c.name.replace(" [Rare]", "").replace(" [rare]", "").capitalize() 235 | rare = ( 236 | self.color_text(" [Rare]", "orange") if "rare" in c.name.lower() else "" 237 | ) 238 | print( 239 | f"{self.color_text(f'{i}.', 'white')} {self.color_text(name.ljust(max_name), c.color)}{self.SPACE_BETWEEN_CROP_INFO}" 240 | f"💰 Cost: {self.color_text(f'${c.cost}'.ljust(max_cost), 'yellow')}{self.SPACE_BETWEEN_CROP_INFO}" 241 | f"💵 Sell: {self.color_text(f'${c.value}'.ljust(max_value), 'bright_yellow')}{self.SPACE_BETWEEN_CROP_INFO}" 242 | f"❤️ Stamina: {self.color_text(f'{c.stamina_cost} ♥'.ljust(max_stamina), 'pink')}{self.SPACE_BETWEEN_CROP_INFO}" 243 | f"⏱️ Time: {f'{c.growth_time}s'.ljust(max_time)}{rare}" 244 | ) 245 | 246 | def harvest_menu(self): 247 | if not self.game.player.has_stamina(0.5): 248 | input(f"{self.color_text('Not enough stamina!', 'red')} Press Enter...") 249 | return 250 | 251 | harvested_value = self.game.farm.harvest_ready_crops() 252 | 253 | if harvested_value > 0: 254 | self.game.player.earn_money(harvested_value) 255 | self.game.player.use_stamina(0.5) 256 | print( 257 | f"{self.color_text(f'Harvested crops worth ${harvested_value}!', 'green')}" 258 | ) 259 | else: 260 | print(f"{self.color_text('Nothing ready to harvest yet!', 'yellow')}") 261 | time.sleep(self.MENU_COOLDOWN_TIME) 262 | 263 | def sleep_menu(self): 264 | self.clear_screen() 265 | print(f"{self.color_text('😴 Sleep Options', 'bright_blue')}\n") 266 | print( 267 | f"{self.color_text('1.', 'cyan')} Sleep until next day {self.color_text(f'(Recover all {TUIConstants.EMOJI_HEART})', 'cyan')}" 268 | ) 269 | print( 270 | f"{self.color_text('2.', 'cyan')} Take a nap (advance time) {self.color_text(f'(+1 {TUIConstants.EMOJI_HEART})', 'cyan')}" 271 | ) 272 | 273 | choice = input(self.display_action_message(cancellable=True)) 274 | if choice == "1": 275 | if not self.game.day_cycle_system.is_night() and not getattr( 276 | self.game.player, "can_sleep_anytime", False 277 | ): 278 | print( 279 | self.color_text( 280 | "\nYou can only sleep at night… try taking a nap.", "red" 281 | ) 282 | ) 283 | time.sleep(self.MENU_COOLDOWN_TIME) 284 | return 285 | _, message = self.game.next_day() 286 | self.game.player.full_restore() 287 | self.game.player.last_sleep_time = datetime.now() 288 | 289 | print( 290 | self.color_text( 291 | "\nYou slept soundly and woke up refreshed the next day!", 292 | "bright_green", 293 | ) 294 | ) 295 | if message: 296 | print(f"{self.color_text('EVENT:', 'bright_blue')} {message}") 297 | time.sleep(self.MENU_COOLDOWN_TIME) 298 | elif choice == "2": 299 | self.game.player.restore_stamina(1) 300 | 301 | self.game.day_cycle_system.current_part_index = ( 302 | self.game.day_cycle_system.current_part_index + 1 303 | ) % len(self.game.day_cycle_system.PARTS) 304 | self.game.day_cycle_system.last_update_time = datetime.now() 305 | print( 306 | self.color_text( 307 | f"\nYou took a nap and time passed... (+1 {TUIConstants.EMOJI_HEART})", 308 | "green", 309 | ) 310 | ) 311 | time.sleep(self.MENU_COOLDOWN_TIME) 312 | 313 | def start_game_loop(self): 314 | while True: 315 | self.display_farm() 316 | print(self.color_text("Actions:", "bright_blue")) 317 | 318 | actions = [] 319 | actions.append( 320 | f"{self.color_text('1.', 'cyan')} {self.color_text('Plant Crop', 'bright_green')}" 321 | ) 322 | actions.append( 323 | f"{self.color_text('2.', 'cyan')} {self.color_text('Harvest Crops', 'grey')}" 324 | ) 325 | actions.append( 326 | f"{self.color_text('3.', 'cyan')} {self.color_text('Next Day', 'grey')}" 327 | ) 328 | actions.append( 329 | f"{self.color_text('4.', 'cyan')} {self.color_text('Sleep/Rest', 'grey')}" 330 | ) 331 | actions.append( 332 | f"{self.color_text('5.', 'cyan')} {self.color_text('Save & Quit', 'grey')}" 333 | ) 334 | actions.append( 335 | f"{self.color_text('6.', 'cyan')} {self.color_text('Reset Game', 'red')}" 336 | ) 337 | 338 | if self.game.merchant_system.is_available( 339 | self.game.day_cycle_system.get_current_part() 340 | ): 341 | actions.append( 342 | f"{self.color_text('7.', 'cyan')} {self.color_text('Joji the Merchant', 'bright_yellow')}" 343 | ) 344 | 345 | if self.game.merchant_system.fishing_unlocked: 346 | actions.append( 347 | f"{self.color_text('8.', 'cyan')} {self.color_text('Go Fishing', 'grey')}" 348 | ) 349 | 350 | if self.game.player.has_farmdex: 351 | actions.append( 352 | f"{self.color_text('9.', 'cyan')} {self.color_text('Farmdex', 'grey')}" 353 | ) 354 | 355 | max_widths = [0, 0, 0] 356 | for i, action in enumerate(actions): 357 | col = i % 3 358 | length = len(self.strip_ansi(action)) 359 | if length > max_widths[col]: 360 | max_widths[col] = length 361 | 362 | for i in range(0, len(actions), 3): 363 | row = actions[i : i + 3] 364 | padded_row = [] 365 | for j, action in enumerate(row): 366 | col_width = max_widths[j] 367 | raw = self.strip_ansi(action) 368 | pad = col_width - len(raw) 369 | padded_row.append(action + (" " * pad)) 370 | print(" | ".join(padded_row)) 371 | 372 | choice = input(self.display_action_message()) 373 | 374 | if choice == "1": 375 | if ( 376 | self.game.day_cycle_system.get_current_part() == "night" 377 | and not getattr(self.game.player, "has_lantern", False) 378 | ): 379 | input( 380 | self.color_text( 381 | "It's too dark to work without a lantern!", "red" 382 | ) 383 | + " Press Enter..." 384 | ) 385 | continue 386 | self.plant_crop_menu() 387 | elif choice == "2": 388 | if ( 389 | self.game.day_cycle_system.get_current_part() == "night" 390 | and not getattr(self.game.player, "has_lantern", False) 391 | ): 392 | input( 393 | self.color_text( 394 | "It's too dark to work without a lantern!", "red" 395 | ) 396 | + " Press Enter..." 397 | ) 398 | continue 399 | self.harvest_menu() 400 | elif choice == "3": 401 | success, message = self.game.next_day() 402 | if success: 403 | print( 404 | f"{self.color_text('Advanced to day', 'blue')} " 405 | f"{self.color_text(self.game.time_system.day, 'bright_blue')}!" 406 | ) 407 | if message: 408 | print(f"{self.color_text('EVENT:', 'bright_blue')} {message}") 409 | time.sleep(self.MENU_COOLDOWN_TIME) 410 | else: 411 | input( 412 | f"{self.color_text('Not enough stamina!', 'red')} Press Enter..." 413 | ) 414 | elif choice == "4": 415 | self.sleep_menu() 416 | elif choice == "5": 417 | if self.game.save(): 418 | print(f"\n{self.color_text('Game saved!', 'green')}") 419 | sys.exit() 420 | elif choice == "6": 421 | confirm = input( 422 | self.color_text("Are you sure you want to reset? (y/n): ", "red") 423 | ) 424 | if confirm.lower() == "y": 425 | self.game.new_game() 426 | print(self.color_text("Game reset!", "green")) 427 | time.sleep(1) 428 | elif choice == "7" and self.game.merchant_system.is_available( 429 | self.game.day_cycle_system.get_current_part() 430 | ): 431 | self.merchant_menu() 432 | elif choice == "8" and self.game.merchant_system.fishing_unlocked: 433 | if ( 434 | self.game.day_cycle_system.get_current_part() == "night" 435 | and not getattr(self.game.player, "has_lantern", False) 436 | ): 437 | input( 438 | self.color_text( 439 | "It's too dark to work without a lantern!", "red" 440 | ) 441 | + " Press Enter..." 442 | ) 443 | continue 444 | self.fishing_menu() 445 | elif choice == "9" and self.game.player.has_farmdex: 446 | self.farmdex_menu() 447 | else: 448 | print(f"{self.color_text('Invalid choice!', 'red')}") 449 | time.sleep(self.MENU_COOLDOWN_TIME) 450 | 451 | def farmdex_menu(self): 452 | self.clear_screen() 453 | print(self.color_text("🦖 Farmdex Collection", "bright_green")) 454 | print( 455 | self.color_text( 456 | f"Fossils Discovered: {len(self.game.player.fossils_found)}/50", "cyan" 457 | ) 458 | ) 459 | print() 460 | all_fossils = [ 461 | "Tyrannosaurus", 462 | "Triceratops", 463 | "Velociraptor", 464 | "Brachiosaurus", 465 | "Stegosaurus", 466 | "Spinosaurus", 467 | "Ankylosaurus", 468 | "Parasaurolophus", 469 | "Allosaurus", 470 | "Diplodocus", 471 | "Iguanodon", 472 | "Archaeopteryx", 473 | "Pteranodon", 474 | "Deinonychus", 475 | "Megalosaurus", 476 | "Pachycephalosaurus", 477 | "Corythosaurus", 478 | "Oviraptor", 479 | "Plateosaurus", 480 | "Styracosaurus", 481 | "Suchomimus", 482 | "Troodon", 483 | "Carnotaurus", 484 | "Sauropelta", 485 | "Albertosaurus", 486 | "Mamenchisaurus", 487 | "Edmontosaurus", 488 | "Herrerasaurus", 489 | "Giganotosaurus", 490 | "Therizinosaurus", 491 | "Kentrosaurus", 492 | "Dilophosaurus", 493 | "Coelophysis", 494 | "Protoceratops", 495 | "Sinraptor", 496 | "Rugops", 497 | "Lambeosaurus", 498 | "Mononykus", 499 | "Torosaurus", 500 | "Rhabdodon", 501 | "Ouranosaurus", 502 | "Microceratus", 503 | "Zuniceratops", 504 | "Einiosaurus", 505 | "Dromaeosaurus", 506 | "Massospondylus", 507 | "Lesothosaurus", 508 | "Noasaurus", 509 | "Gasparinisaura", 510 | "Minmi", 511 | ] 512 | columns = 3 513 | rows = (len(all_fossils) + columns - 1) // columns 514 | fossil_entries = [] 515 | 516 | for name in all_fossils: 517 | if name in self.game.player.fossils_found: 518 | fossil_entries.append(self.color_text(name, "bright_green")) 519 | else: 520 | fossil_entries.append(self.color_text("?????", "gray")) 521 | 522 | for row in range(rows): 523 | line = "" 524 | for col in range(columns): 525 | idx = row + col * rows 526 | if idx < len(fossil_entries): 527 | entry = fossil_entries[idx] 528 | entry_padded = entry + " " * (20 - len(self.strip_ansi(entry))) 529 | line += entry_padded 530 | print(line) 531 | input(self.color_text("\n(Press Enter to return)", "white")) 532 | 533 | def merchant_menu(self): 534 | self.clear_screen() 535 | print(self.color_text("🧙‍♂️ Joji, the Morning Merchant", "bright_yellow")) 536 | print(self.color_text("═" * 40, "bright_cyan")) 537 | print(self.color_text("Welcome! Take a look at my goods:", "white")) 538 | print(self.color_text(f"\n💰 Money: ${self.game.player.money}", "white")) 539 | print() 540 | 541 | print(self.color_text("🌱 Seeds:", "bright_blue")) 542 | for key, seed in self.game.merchant_system.inventory["seeds"].items(): 543 | already_unlocked = seed["crop"] in self.game.crop_system.unlocked_crops 544 | item_name = self.color_text(key, "gray" if already_unlocked else "cyan") 545 | unlock = self.color_text(f"(Unlocks {seed['crop'].capitalize()})", "grey") 546 | inflated = ( 547 | hasattr(self.game, "market_inflated") and self.game.market_inflated 548 | ) 549 | price_display = f"${seed['price'] * 2}" if inflated else f"${seed['price']}" 550 | inflated_tag = self.color_text(" [INFLATED]", "red") if inflated else "" 551 | print(f" - {item_name}: {price_display} {unlock}{inflated_tag}") 552 | 553 | print() 554 | print(self.color_text("🎁 Items:", "bright_blue")) 555 | for key, item in self.game.merchant_system.inventory["items"].items(): 556 | already_owned = False 557 | if ( 558 | item.get("unlocks") == "fishing" 559 | and self.game.merchant_system.fishing_unlocked 560 | ): 561 | already_owned = True 562 | elif ( 563 | item.get("effect") == "unlock_farmdex" and self.game.player.has_farmdex 564 | ): 565 | already_owned = True 566 | elif item.get("effect") == "cosmetic": 567 | already_owned = ( 568 | hasattr(self.game.player, "bought_hat") 569 | and self.game.player.bought_hat 570 | ) 571 | elif item.get("effect") == "unlock_night_work": 572 | already_owned = getattr(self.game.player, "has_lantern", False) 573 | elif ( 574 | item.get("effect") == "increase_event_chance" 575 | and hasattr(self.game.player, "event_bonus") 576 | and self.game.player.event_bonus == "lucky_egg" 577 | ): 578 | already_owned = True 579 | elif ( 580 | item.get("effect") == "increase_max_stamina" 581 | and self.game.player.max_stamina > 5 582 | ): 583 | already_owned = True 584 | elif item.get("effect") == "cosmetic": 585 | already_owned = ( 586 | hasattr(self.game.player, "bought_hat") 587 | and self.game.player.bought_hat 588 | ) 589 | elif item.get("effect") == "unlock_anytime_sleep": 590 | already_owned = getattr(self.game.player, "can_sleep_anytime", False) 591 | 592 | item_name = self.color_text(key, "gray" if already_owned else "cyan") 593 | if "unlocks" in item: 594 | detail = self.color_text( 595 | f"(Unlocks {item['unlocks'].capitalize()})", "grey" 596 | ) 597 | elif "effect" in item: 598 | readable_effects = { 599 | "cosmetic": "Visual cosmetic item", 600 | "increase_event_chance": "Boosts daily events: 80% chance to occur each day!", 601 | "increase_max_stamina": "Double your max stamina", 602 | "unlock_night_work": "Allow you to work at night", 603 | "unlock_anytime_sleep": "Sleep anytime to recover stamina", 604 | } 605 | effect_description = readable_effects.get(item.get("effect", ""), "") 606 | detail = ( 607 | self.color_text(f"({effect_description})", "grey") 608 | if effect_description 609 | else "" 610 | ) 611 | else: 612 | detail = "" 613 | inflated = ( 614 | hasattr(self.game, "market_inflated") and self.game.market_inflated 615 | ) 616 | price_display = f"${item['price'] * 2}" if inflated else f"${item['price']}" 617 | inflated_tag = self.color_text(" [INFLATED]", "red") if inflated else "" 618 | print(f" - {item_name}: {price_display} {detail}{inflated_tag}") 619 | 620 | choice = input( 621 | self.display_action_message( 622 | message="What would you like to buy?", 623 | cancellable=True, 624 | cancel_message=f"(type {self.color_text('item_key', 'cyan')} or '0' to cancel): ", 625 | ) 626 | ).strip() 627 | 628 | if choice == "0": 629 | return 630 | narrative = False 631 | item = None 632 | if choice in self.game.merchant_system.inventory["seeds"]: 633 | msg = self.game.merchant_system.buy_seed(choice) 634 | elif choice in self.game.merchant_system.inventory["items"]: 635 | item = self.game.merchant_system.inventory["items"][choice] 636 | narrative = item.get("narrative", False) 637 | msg = self.game.merchant_system.buy_item(choice) 638 | else: 639 | msg = "Invalid option." 640 | 641 | error_keywords = ["invalid", "not enough"] 642 | is_error = msg is None or any(kw in msg.lower() for kw in error_keywords) 643 | 644 | if is_error: 645 | print(self.color_text(msg, "red")) 646 | time.sleep(self.MENU_COOLDOWN_TIME) 647 | elif narrative: 648 | print(self.color_text(msg, "green")) 649 | input(self.color_text("\n(Press Enter to continue)", "white")) 650 | else: 651 | print(self.color_text(msg, "green")) 652 | time.sleep(self.MENU_COOLDOWN_TIME) 653 | 654 | def fishing_menu(self): 655 | self.clear_screen() 656 | print(self.color_text("🎣 Fishing Spot\n", "bright_blue")) 657 | 658 | print( 659 | f"{self.color_text('1.', 'cyan')} Go fishing {self.color_text('(-2 ♥)', 'red')}" 660 | ) 661 | print(f"{self.color_text('2.', 'cyan')} Sell all fish") 662 | 663 | choice = input(self.display_action_message(cancellable=True)) 664 | if choice == "1": 665 | result = self.game.fishing_system.fish() 666 | elif choice == "2": 667 | result = self.game.fishing_system.sell_all_fish() 668 | else: 669 | return 670 | 671 | print(self.color_text(result, "green")) 672 | time.sleep(2) 673 | --------------------------------------------------------------------------------