├── 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 |
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 |
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 |
4 |
--------------------------------------------------------------------------------
/web/assets/date_label.svg:
--------------------------------------------------------------------------------
1 |
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 |
21 |
22 |
23 |
24 |
25 |
29 |
33 |
37 |
41 |
45 |
49 |
53 |
57 |
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 |
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 |
--------------------------------------------------------------------------------