Virtual Plant Care Simulator

import pygame

import sys

import json

import math

import time

from datetime import datetime


# -------------------------

# Config

# -------------------------

SAVE_FILE = "plant_save.json"

WIDTH, HEIGHT = 720, 560

FPS = 60


# In-game time scale: how many in-game minutes pass per real second

TIME_SCALE_MIN_PER_SEC = 5.0            # 1 real sec = 5 in-game minutes

TURBO_MULTIPLIER = 10.0                 # Turbo mode speed-up


# Decay & growth tuning (per in-game minute)

MOISTURE_DECAY = 0.10                   # moisture decreases per minute

NUTRIENT_DECAY = 0.02                   # nutrients decreases per minute

HEALTH_INC_RATE = 0.02                  # health regen per minute (good care)

HEALTH_DEC_RATE = 0.05                  # health loss per minute (bad care)

HEALTH_SEVERE_PENALTY = 0.20            # extra loss if moisture is 0


WATER_AMOUNT = 35                       # moisture gained per watering

FERTILIZER_AMOUNT = 25                  # nutrients gained per fertilize


GOOD_MOISTURE = 50

GOOD_NUTRIENTS = 50

LOW_MOISTURE = 30

LOW_NUTRIENTS = 30


# Growth stages by age (in-game minutes)

STAGES = [

    ("Seed", 0),

    ("Sprout", 12 * 60),         # 12 hours

    ("Young", 36 * 60),          # 1.5 days

    ("Mature", 3 * 24 * 60),     # 3 days

    ("Blooming", 7 * 24 * 60),   # 1 week

]


# Colors

WHITE = (255, 255, 255)

BG = (238, 245, 239)

TEXT = (34, 40, 49)

CARD = (245, 250, 245)

GREEN = (72, 157, 77)

YELLOW = (240, 190, 40)

RED = (220, 76, 70)

BLUE = (66, 135, 245)

BROWN = (99, 68, 48)

SOIL = (84, 59, 42)

GREY = (200, 205, 210)


pygame.init()

pygame.display.set_caption("Virtual Plant Care Simulator 🌱")

screen = pygame.display.set_mode((WIDTH, HEIGHT))

clock = pygame.time.Clock()

FONT = pygame.font.SysFont("consolas", 18)

FONT_BIG = pygame.font.SysFont("consolas", 24, bold=True)


# -------------------------

# Helpers

# -------------------------

def clamp(v, lo=0.0, hi=100.0):

    return max(lo, min(hi, v))


def now_iso():

    return datetime.now().isoformat()


def load_state():

    try:

        with open(SAVE_FILE, "r", encoding="utf-8") as f:

            data = json.load(f)

        # basic sanity defaults

        data.setdefault("name", "Leafy")

        data.setdefault("age_minutes", 0.0)

        data.setdefault("moisture", 70.0)

        data.setdefault("nutrients", 60.0)

        data.setdefault("health", 80.0)

        data.setdefault("alive", True)

        data.setdefault("last_update_iso", now_iso())

        data.setdefault("turbo", False)

        return data

    except FileNotFoundError:

        return {

            "name": "Leafy",

            "age_minutes": 0.0,

            "moisture": 70.0,

            "nutrients": 60.0,

            "health": 80.0,

            "alive": True,

            "last_update_iso": now_iso(),

            "turbo": False

        }


def save_state(state):

    with open(SAVE_FILE, "w", encoding="utf-8") as f:

        json.dump(state, f, indent=2)


def stage_from_age(age_min):

    current = STAGES[0][0]

    for name, threshold in STAGES:

        if age_min >= threshold:

            current = name

        else:

            break

    return current


def draw_bar(surface, x, y, w, h, pct, color, label):

    pygame.draw.rect(surface, GREY, (x, y, w, h), border_radius=6)

    inner_w = int(w * clamp(pct/100.0, 0, 1))

    pygame.draw.rect(surface, color, (x, y, inner_w, h), border_radius=6)

    txt = FONT.render(f"{label}: {int(pct)}%", True, TEXT)

    surface.blit(txt, (x + 8, y + h//2 - txt.get_height()//2))


def draw_button(surface, rect, text, active=True):

    col = CARD if active else (230,230,230)

    pygame.draw.rect(surface, col, rect, border_radius=8)

    pygame.draw.rect(surface, GREY, rect, width=1, border_radius=8)

    t = FONT.render(text, True, TEXT if active else (140,140,140))

    surface.blit(t, (rect.x + rect.w//2 - t.get_width()//2,

                     rect.y + rect.h//2 - t.get_height()//2))


def nice_time(minutes):

    d = int(minutes // (24*60))

    h = int((minutes % (24*60)) // 60)

    m = int(minutes % 60)

    parts = []

    if d: parts.append(f"{d}d")

    if h: parts.append(f"{h}h")

    parts.append(f"{m}m")

    return " ".join(parts)


# -------------------------

# Plant rendering

# -------------------------

def draw_pot(surface, cx, base_y):

    pot_w = 160

    pot_h = 70

    rim_h = 18

    # pot body

    pygame.draw.rect(surface, BROWN, (cx - pot_w//2, base_y - pot_h, pot_w, pot_h), border_radius=10)

    # soil

    pygame.draw.rect(surface, SOIL, (cx - pot_w//2 + 10, base_y - pot_h + rim_h, pot_w - 20, pot_h - rim_h - 6), border_radius=8)

    # rim

    pygame.draw.rect(surface, (120, 84, 60), (cx - pot_w//2, base_y - pot_h - 6, pot_w, rim_h), border_radius=8)


def draw_plant(surface, cx, base_y, age_min, health, moisture, nutrients, t):

    """

    Procedural plant:

    - Height scales with age & health

    - Stem sways using sin wave

    - Leaves hue via moisture/nutrients

    """

    # growth factor

    stage = stage_from_age(age_min)

    stage_index = [s[0] for s in STAGES].index(stage)

    height = 40 + stage_index * 35 + (health/100.0) * 30  # 40..~220


    # sway

    sway = math.sin(t * 2.0) * 8  # px

    top_x = cx + sway

    top_y = base_y - height


    # stem

    points = []

    segments = 8

    for i in range(segments+1):

        y = base_y - (height * i / segments)

        x = cx + math.sin(t*2 + i*0.6) * (8 * (i/segments))

        points.append((x, y))

    # draw stem as polyline (thicker at base)

    for i in range(len(points)-1):

        w = int(8 - 6*(i/segments))

        pygame.draw.line(surface, (46, 120, 52), points[i], points[i+1], max(2, w))


    # leaf color based on care

    care_score = (moisture/100.0 + nutrients/100.0 + health/100.0) / 3.0

    leaf_col = (

        int(60 + 120 * care_score),    # R

        int(120 + 120 * care_score),   # G

        int(60 + 80 * care_score)      # B

    )


    # leaves along stem

    for i in range(2, segments):

        px, py = points[i]

        lr = 18 + 6*(i/segments)

        angle = math.sin(t*3 + i) * 0.5 + (1 if i%2==0 else -1)*0.6

        # left leaf

        leaf_poly(surface, px, py, lr, angle, leaf_col)

        # right leaf

        leaf_poly(surface, px, py, lr, -angle, leaf_col)


    # bud/flower at top for late stages

    if stage in ("Mature", "Blooming"):

        r = 10 if stage == "Mature" else 14 + 4*math.sin(t*4)

        pygame.draw.circle(surface, (240, 110, 130), (int(points[-1][0]), int(points[-1][1])-8), int(abs(r)))


def leaf_poly(surface, x, y, r, angle, col):

    # Draw a simple rotated leaf (ellipse-ish polygon)

    pts = []

    steps = 10

    for i in range(steps+1):

        theta = -math.pi/2 + math.pi * (i/steps)

        px = x + r * math.cos(theta) * 0.4

        py = y + r * math.sin(theta)

        # rotate around (x,y)

        rx = x + (px - x) * math.cos(angle) - (py - y) * math.sin(angle)

        ry = y + (px - x) * math.sin(angle) + (py - y) * math.cos(angle)

        pts.append((rx, ry))

    pygame.draw.polygon(surface, col, pts)


# -------------------------

# Update logic

# -------------------------

def update_state(state, dt_minutes):

    if not state["alive"]:

        return


    # age grows faster when healthy

    health_factor = 0.5 + (state["health"]/100.0) * 0.5  # 0.5..1.0

    state["age_minutes"] += dt_minutes * health_factor


    # resource decay

    state["moisture"] = clamp(state["moisture"] - MOISTURE_DECAY * dt_minutes)

    state["nutrients"] = clamp(state["nutrients"] - NUTRIENT_DECAY * dt_minutes)


    # health dynamics

    good_care = (state["moisture"] >= GOOD_MOISTURE) and (state["nutrients"] >= GOOD_NUTRIENTS)

    low_care = (state["moisture"] < LOW_MOISTURE) or (state["nutrients"] < LOW_NUTRIENTS)


    if good_care:

        state["health"] = clamp(state["health"] + HEALTH_INC_RATE * dt_minutes)

    if low_care:

        penalty = HEALTH_DEC_RATE * dt_minutes

        # severe dehydration hurts more

        if state["moisture"] <= 0.1:

            penalty += HEALTH_SEVERE_PENALTY * dt_minutes

        state["health"] = clamp(state["health"] - penalty)


    if state["health"] <= 0.0:

        state["alive"] = False


def water(state):

    if not state["alive"]: return

    state["moisture"] = clamp(state["moisture"] + WATER_AMOUNT)


def fertilize(state):

    if not state["alive"]: return

    state["nutrients"] = clamp(state["nutrients"] + FERTILIZER_AMOUNT)


def revive(state):

    # Soft revive to keep testing

    state["alive"] = True

    state["health"] = max(state["health"], 40.0)

    state["moisture"] = max(state["moisture"], 40.0)


# -------------------------

# UI setup

# -------------------------

BUTTONS = {

    "water": pygame.Rect(40, 460, 140, 36),

    "fertilize": pygame.Rect(200, 460, 140, 36),

    "save": pygame.Rect(360, 460, 120, 36),

    "turbo": pygame.Rect(500, 460, 160, 36),

    "revive": pygame.Rect(40, 510, 120, 34),

    "new": pygame.Rect(180, 510, 120, 34),

    "quit": pygame.Rect(320, 510, 120, 34),

}


def reset_new_plant():

    return {

        "name": "Leafy",

        "age_minutes": 0.0,

        "moisture": 70.0,

        "nutrients": 60.0,

        "health": 80.0,

        "alive": True,

        "last_update_iso": now_iso(),

        "turbo": False

    }


# -------------------------

# Main loop

# -------------------------

def main():

    state = load_state()


    last_real = time.time()

    running = True


    while running:

        real_now = time.time()

        real_dt = real_now - last_real

        last_real = real_now


        # Time scale

        speed = TIME_SCALE_MIN_PER_SEC * (TURBO_MULTIPLIER if state.get("turbo") else 1.0)

        dt_minutes = real_dt * speed


        # Update plant based on dt

        update_state(state, dt_minutes)


        # Draw

        screen.fill(BG)


        # Left info card

        pygame.draw.rect(screen, CARD, (24, 24, 280, 410), border_radius=12)

        pygame.draw.rect(screen, GREY, (24, 24, 280, 410), width=1, border_radius=12)


        title = FONT_BIG.render("Plant Status", True, TEXT)

        screen.blit(title, (40, 36))


        # Bars

        draw_bar(screen, 40, 80, 240, 20, state["health"], GREEN if state["health"] >= 50 else YELLOW if state["health"] >= 25 else RED, "Health")

        draw_bar(screen, 40, 120, 240, 20, state["moisture"], BLUE if state["moisture"] >= 40 else YELLOW if state["moisture"] >= 20 else RED, "Moisture")

        draw_bar(screen, 40, 160, 240, 20, state["nutrients"], GREEN if state["nutrients"] >= 40 else YELLOW if state["nutrients"] >= 20 else RED, "Nutrients")


        # Text stats

        lines = [

            f"Name: {state['name']}",

            f"Stage: {stage_from_age(state['age_minutes'])}",

            f"Age: {nice_time(state['age_minutes'])}",

            f"Alive: {'Yes' if state['alive'] else 'No'}",

            f"Speed: {'Turbo' if state.get('turbo') else 'Normal'} (x{int(TURBO_MULTIPLIER) if state.get('turbo') else 1})",

            "",

            "Tips:",

            "- Keep moisture & nutrients ≥ 50.",

            "- Turbo to fast-forward growth.",

            "- Save to keep progress.",

            "- Revive for testing if it dies."

        ]

        y = 200

        for ln in lines:

            txt = FONT.render(ln, True, TEXT)

            screen.blit(txt, (40, y))

            y += 22


        # Plant area (right)

        pygame.draw.rect(screen, CARD, (324, 24, 372, 410), border_radius=12)

        pygame.draw.rect(screen, GREY, (324, 24, 372, 410), width=1, border_radius=12)

        arena = pygame.Rect(324, 24, 372, 410)

        cx = arena.x + arena.w//2

        base_y = arena.y + arena.h - 40


        # Ground line

        pygame.draw.line(screen, (180, 180, 180), (arena.x + 20, base_y), (arena.right - 20, base_y), 2)


        # Draw pot & plant

        draw_pot(screen, cx, base_y)

        t = pygame.time.get_ticks() / 1000.0

        draw_plant(screen, cx, base_y, state["age_minutes"], state["health"], state["moisture"], state["nutrients"], t)


        # Buttons

        draw_button(screen, BUTTONS["water"], "💧 Water", active=state["alive"])

        draw_button(screen, BUTTONS["fertilize"], "🧪 Fertilize", active=state["alive"])

        draw_button(screen, BUTTONS["save"], "💾 Save")

        draw_button(screen, BUTTONS["turbo"], f"⚡ Turbo: {'ON' if state.get('turbo') else 'OFF'}")

        draw_button(screen, BUTTONS["revive"], "❤️ Revive")

        draw_button(screen, BUTTONS["new"], "🌱 New Plant")

        draw_button(screen, BUTTONS["quit"], "🚪 Quit")


        # Warning labels

        warn_y = 430

        if state["moisture"] < LOW_MOISTURE:

            wtxt = FONT.render("Low Moisture! Water me 💧", True, BLUE)

            screen.blit(wtxt, (40, warn_y)); warn_y += 22

        if state["nutrients"] < LOW_NUTRIENTS:

            wtxt = FONT.render("Low Nutrients! Fertilize 🧪", True, (150, 100, 20))

            screen.blit(wtxt, (40, warn_y)); warn_y += 22

        if not state["alive"]:

            wtxt = FONT_BIG.render("The plant has died. Try Revive or New Plant.", True, RED)

            screen.blit(wtxt, (40, warn_y))


        pygame.display.flip()


        # Events

        for event in pygame.event.get():

            if event.type == pygame.QUIT:

                save_state(state)

                running = False

            elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:

                mx, my = event.pos

                if BUTTONS["water"].collidepoint(mx, my) and state["alive"]:

                    water(state)

                elif BUTTONS["fertilize"].collidepoint(mx, my) and state["alive"]:

                    fertilize(state)

                elif BUTTONS["save"].collidepoint(mx, my):

                    save_state(state)

                elif BUTTONS["turbo"].collidepoint(mx, my):

                    state["turbo"] = not state.get("turbo", False)

                elif BUTTONS["revive"].collidepoint(mx, my):

                    revive(state)

                elif BUTTONS["new"].collidepoint(mx, my):

                    state = reset_new_plant()

                elif BUTTONS["quit"].collidepoint(mx, my):

                    save_state(state)

                    running = False

            elif event.type == pygame.KEYDOWN:

                if event.key == pygame.K_w and state["alive"]:

                    water(state)

                elif event.key == pygame.K_f and state["alive"]:

                    fertilize(state)

                elif event.key == pygame.K_s:

                    save_state(state)

                elif event.key == pygame.K_t:

                    state["turbo"] = not state.get("turbo", False)

                elif event.key == pygame.K_r:

                    revive(state)

                elif event.key == pygame.K_n:

                    state = reset_new_plant()

                elif event.key == pygame.K_ESCAPE:

                    save_state(state)

                    running = False


        clock.tick(FPS)


    pygame.quit()

    sys.exit()


if __name__ == "__main__":

    main()


No comments: