Files
UltraChat/jrpg/jrpg_game_en.py
2026-03-17 00:16:49 +05:00

1018 lines
39 KiB
Python

"""
Pixel Village JRPG
A cozy little JRPG with town, forest, and turn-based combat!
Requirements: pip install pygame
Run: python jrpg_game.py
Controls:
Arrow Keys - Move
E / Space - Interact / Confirm
Escape - Cancel / Back
"""
import pygame
import sys
import random
import math
pygame.init()
# ── Constants ──────────────────────────────────────────────────────────────
W, H = 640, 480
TILE = 32
FPS = 60
# Bright pixel palette
C = {
"bg": (20, 20, 35),
"sky": (100, 180, 255),
"grass": (80, 200, 80),
"dark_grass":(60, 160, 60),
"path": (210, 180, 130),
"water": (60, 160, 220),
"tree": (40, 140, 40),
"tree_top": (50, 200, 60),
"trunk": (140, 90, 40),
"house_w": (240, 220, 200),
"house_r": (220, 60, 60),
"store_w": (200, 220, 255),
"store_r": (60, 100, 220),
"door": (140, 80, 30),
"window": (180, 230, 255),
"sign": (240, 200, 100),
"white": (255, 255, 255),
"black": (0, 0, 0),
"gray": (160, 160, 160),
"dark": (30, 30, 50),
"panel": (20, 20, 50),
"panel2": (40, 40, 80),
"border": (120, 100, 200),
"gold": (255, 210, 50),
"red": (240, 60, 60),
"green": (80, 220, 100),
"blue": (80, 160, 240),
"pink": (255, 140, 200),
"purple": (180, 100, 255),
"yellow": (255, 240, 80),
"orange": (255, 160, 40),
"hp_red": (220, 50, 50),
"mp_blue": (50, 100, 220),
"xp_green": (50, 200, 80),
}
screen = pygame.display.set_mode((W, H), pygame.SCALED | pygame.RESIZABLE)
pygame.display.set_caption("Pixel Village JRPG")
clock = pygame.time.Clock()
# Fonts
try:
font_lg = pygame.font.SysFont("couriernew", 22, bold=True)
font_md = pygame.font.SysFont("couriernew", 16, bold=True)
font_sm = pygame.font.SysFont("couriernew", 13)
except:
font_lg = pygame.font.SysFont(None, 26)
font_md = pygame.font.SysFont(None, 18)
font_sm = pygame.font.SysFont(None, 14)
# ── Helpers ────────────────────────────────────────────────────────────────
def txt(surface, text, x, y, color, fnt=None):
fnt = fnt or font_md
surface.blit(fnt.render(str(text), True, color), (x, y))
def draw_bar(surf, x, y, w, h, val, mx, color, bg=(60,60,60)):
pygame.draw.rect(surf, bg, (x, y, w, h))
fill = int(w * max(0, val) / max(1, mx))
pygame.draw.rect(surf, color, (x, y, fill, h))
pygame.draw.rect(surf, C["white"], (x, y, w, h), 1)
def panel(surf, x, y, w, h, border=True):
pygame.draw.rect(surf, C["panel"], (x, y, w, h))
if border:
pygame.draw.rect(surf, C["border"], (x, y, w, h), 2)
def flash_color(base, t, amp=40):
v = int(amp * abs(math.sin(t * 4)))
return tuple(min(255, c + v) for c in base)
# ── Pixel Art Drawing Helpers ──────────────────────────────────────────────
def draw_tree(surf, x, y):
pygame.draw.rect(surf, C["trunk"], (x+12, y+28, 8, 12))
pygame.draw.polygon(surf, C["tree"], [(x+16,y),(x+2,y+28),(x+30,y+28)])
pygame.draw.polygon(surf, C["tree_top"], [(x+16,y+4),(x+4,y+24),(x+28,y+24)])
def draw_house(surf, x, y, color_w=None, color_r=None):
cw = color_w or C["house_w"]
cr = color_r or C["house_r"]
pygame.draw.rect(surf, cw, (x, y+16, 48, 32))
pygame.draw.polygon(surf, cr, [(x,y+16),(x+24,y),(x+48,y+16)])
pygame.draw.rect(surf, C["door"], (x+18, y+32, 12, 16))
pygame.draw.rect(surf, C["window"], (x+4, y+22, 10, 10))
pygame.draw.rect(surf, C["window"], (x+34, y+22, 10, 10))
def draw_store(surf, x, y):
draw_house(surf, x, y, C["store_w"], C["store_r"])
# sign
pygame.draw.rect(surf, C["sign"], (x+8, y+12, 32, 8))
txt(surf, "SHOP", x+9, y+12, C["dark"], font_sm)
def draw_player(surf, x, y, frame=0, facing=0):
# Simple cute character
bob = int(math.sin(frame * 0.3) * 2)
bx, by = x, y + bob
# body
pygame.draw.rect(surf, C["blue"], (bx+5, by+10, 12, 12))
# head
pygame.draw.rect(surf, (255,210,170), (bx+4, by+2, 14, 12))
# hair
pygame.draw.rect(surf, C["orange"], (bx+4, by+2, 14, 5))
# eyes
ex = bx+7 if facing != 2 else bx+11
pygame.draw.rect(surf, C["dark"], (ex, by+6, 2, 2))
pygame.draw.rect(surf, C["dark"], (ex+4, by+6, 2, 2))
# legs
lx = bx+5 + (2 if frame % 20 < 10 else -1)
pygame.draw.rect(surf, C["dark"], (lx, by+22, 4, 6))
pygame.draw.rect(surf, C["dark"], (lx+6, by+22, 4, 6))
def draw_npc_mitsuha(surf, x, y, frame=0):
bob = int(math.sin(frame * 0.2) * 1.5)
bx, by = x, y + bob
pygame.draw.rect(surf, C["pink"], (bx+5, by+10, 12, 12))
pygame.draw.rect(surf, (255,210,170), (bx+4, by+2, 14, 12))
pygame.draw.rect(surf, C["purple"], (bx+4, by+2, 14, 5))
# ponytail
pygame.draw.rect(surf, C["purple"], (bx+14, by+4, 4, 10))
pygame.draw.rect(surf, C["dark"], (bx+7, by+6, 2, 2))
pygame.draw.rect(surf, C["dark"], (bx+11, by+6, 2, 2))
pygame.draw.rect(surf, C["pink"], (bx+5, by+22, 4, 6))
pygame.draw.rect(surf, C["pink"], (bx+11, by+22, 4, 6))
def draw_slime(surf, x, y, frame=0, color=None):
c = color or C["green"]
bounce = int(abs(math.sin(frame * 0.15)) * 3)
bx, by = x, y - bounce
# body
pygame.draw.ellipse(surf, c, (bx+2, by+10, 20, 16))
pygame.draw.ellipse(surf, tuple(min(255,v+40) for v in c), (bx+4, by+10, 16, 10))
# eyes
pygame.draw.rect(surf, C["white"], (bx+6, by+13, 4, 4))
pygame.draw.rect(surf, C["white"], (bx+14, by+13, 4, 4))
pygame.draw.rect(surf, C["dark"], (bx+7, by+14, 2, 2))
pygame.draw.rect(surf, C["dark"], (bx+15, by+14, 2, 2))
def draw_fairy(surf, x, y, frame=0):
bob = int(math.sin(frame * 0.2) * 4)
bx, by = x, y + bob
# wings
wc = (200, 220, 255, 180)
wing_surf = pygame.Surface((24, 14), pygame.SRCALPHA)
pygame.draw.ellipse(wing_surf, (200,220,255,160), (0,0,10,10))
pygame.draw.ellipse(wing_surf, (200,220,255,160), (14,2,10,8))
surf.blit(wing_surf, (bx+4, by+8))
# body
pygame.draw.circle(surf, C["yellow"], (bx+12, by+12), 7)
pygame.draw.circle(surf, C["white"], (bx+12, by+12), 5)
pygame.draw.rect(surf, C["dark"], (bx+10, by+10, 2, 2))
pygame.draw.rect(surf, C["dark"], (bx+13, by+10, 2, 2))
# ── Game Data ──────────────────────────────────────────────────────────────
ITEMS = {
"Potion": {"desc": "Restores 30 HP", "price": 50, "effect": ("hp", 30)},
"Hi-Potion": {"desc": "Restores 80 HP", "price": 120, "effect": ("hp", 80)},
"Ether": {"desc": "Restores 20 MP", "price": 60, "effect": ("mp", 20)},
"Elixir": {"desc": "Restores all HP", "price": 300, "effect": ("hp", 9999)},
}
MONSTERS = [
{"name": "Green Slime", "hp": 20, "mp": 0, "atk": 5, "def": 2, "xp": 10, "gold": 8, "color": C["green"]},
{"name": "Blue Slime", "hp": 28, "mp": 0, "atk": 6, "def": 3, "xp": 14, "gold": 10, "color": C["blue"]},
{"name": "Pink Fairy", "hp": 18, "mp": 15, "atk": 8, "def": 1, "xp": 18, "gold": 14, "color": C["pink"]},
{"name": "Forest Wisp", "hp": 35, "mp": 20, "atk": 10, "def": 4, "xp": 25, "gold": 20, "color": C["purple"]},
]
MITSUHA_LINES = [
"Hi there! Beautiful day, isn't it?",
"I heard there are cute little\nmonsters in the forest!",
"Be careful out there, okay?\nBut also have fun!",
"The shop has great potions~",
"I love living in this village!",
"Oh! You look stronger today!",
]
HOME_LINES = [
"A cozy home. You feel rested!",
"Sweet dreams are made of these~",
"Home sweet home!",
]
# ── Player ─────────────────────────────────────────────────────────────────
class Player:
def __init__(self):
self.name = "Hero"
self.x, self.y = 5, 6
self.hp, self.max_hp = 80, 80
self.mp, self.max_mp = 30, 30
self.atk = 15
self.dfn = 5
self.xp, self.xp_next = 0, 50
self.level = 1
self.gold = 100
self.inventory = {"Potion": 2}
self.frame = 0
self.facing = 1 # 0=up 1=down 2=left 3=right
self.px = self.x * TILE # pixel x for smooth movement
self.py = self.y * TILE
def gain_xp(self, amount):
msgs = []
self.xp += amount
while self.xp >= self.xp_next:
self.xp -= self.xp_next
self.level += 1
self.xp_next = int(self.xp_next * 1.4)
self.max_hp += 15
self.hp = self.max_hp
self.max_mp += 5
self.mp = self.max_mp
self.atk += 3
self.dfn += 1
msgs.append(f"Level UP! Now Lv.{self.level}!")
return msgs
def add_item(self, item):
self.inventory[item] = self.inventory.get(item, 0) + 1
def use_item(self, item):
if self.inventory.get(item, 0) <= 0:
return False, "No items left!"
eff = ITEMS[item]["effect"]
self.inventory[item] -= 1
if self.inventory[item] == 0:
del self.inventory[item]
if eff[0] == "hp":
healed = min(eff[1], self.max_hp - self.hp)
self.hp = min(self.max_hp, self.hp + eff[1])
return True, f"Restored {healed} HP!"
elif eff[0] == "mp":
self.mp = min(self.max_mp, self.mp + eff[1])
return True, f"Restored {eff[1]} MP!"
return False, "Nothing happened."
# ── Maps ───────────────────────────────────────────────────────────────────
# T=town, F=forest
TOWN_MAP = [
"WWWWWWWWWWWWWWWWWWWW",
"W..................W",
"W..H.......S.....tW",
"W..................W",
"W...........ttt...W",
"W....t............W",
"W..................W",
"W....P.....M......W",
"W..................W",
"W..........t......W",
"W...##.....t......W",
"W...##............W",
"W.................W",
"WWWWWWWWWFWWWWWWWWW",
]
FOREST_MAP = [
"TTTTTTTTTTTTTTTTTTTT",
"T.t....t....t.....T",
"T....t.....t......T",
"T.t........t....t.T",
"T.....t...........T",
"T....t...t........T",
"T.........t.......T",
"T...t.............T",
"T....t....t.......T",
"T.................T",
"T.....t...........T",
"T.....t...........T",
"T.................T",
"TTTTTTTTTTTTTTTTTTTT",
]
# W=wall, H=home, S=store, t=tree, #=water, P=player start, M=Mitsuha, F=forest entrance, T=town exit
# ── Camera ─────────────────────────────────────────────────────────────────
class Camera:
def __init__(self):
self.ox = 0
self.oy = 0
def update(self, px, py, map_w, map_h):
self.ox = px - W // 2 + TILE // 2
self.oy = py - H // 2 + TILE // 2
self.ox = max(0, min(self.ox, map_w * TILE - W))
self.oy = max(0, min(self.oy, map_h * TILE - H))
def world_to_screen(self, wx, wy):
return wx - self.ox, wy - self.oy
# ── Battle System ──────────────────────────────────────────────────────────
class Battle:
def __init__(self, player, monster_data):
self.player = player
self.mon = dict(monster_data)
self.mon_hp = self.mon["hp"]
self.mon_max_hp = self.mon["hp"]
self.state = "player_turn" # player_turn, enemy_turn, win, lose
self.log = [f"A {self.mon['name']} appeared!"]
self.frame = 0
self.anim = None # ("shake_player"/"shake_enemy", timer)
self.selected = 0 # menu cursor
self.menu = "main" # main, items
self.item_list = []
self.shake_player = 0
self.shake_enemy = 0
self.result_msgs = []
def add_log(self, msg):
self.log.append(msg)
if len(self.log) > 4:
self.log.pop(0)
def player_attack(self):
dmg = max(1, self.player.atk - self.mon["def"] + random.randint(-3, 3))
self.mon_hp -= dmg
self.add_log(f"You dealt {dmg} damage!")
self.shake_enemy = 10
if self.mon_hp <= 0:
self.mon_hp = 0
self.end_battle(True)
else:
self.state = "enemy_turn"
def player_magic(self):
if self.player.mp < 8:
self.add_log("Not enough MP!")
return
self.player.mp -= 8
dmg = max(1, self.player.atk * 2 - self.mon["def"] + random.randint(-2, 5))
self.mon_hp -= dmg
self.add_log(f"Magic! Dealt {dmg} dmg! (-8 MP)")
self.shake_enemy = 12
if self.mon_hp <= 0:
self.mon_hp = 0
self.end_battle(True)
else:
self.state = "enemy_turn"
def player_defend(self):
self.add_log("You brace for impact!")
self.player.dfn += 5
self._defending = True
self.state = "enemy_turn"
def enemy_turn(self):
if self.state != "enemy_turn":
return
dmg = max(1, self.mon["atk"] - self.player.dfn + random.randint(-2, 3))
self.player.hp -= dmg
self.add_log(f"{self.mon['name']} dealt {dmg} dmg!")
self.shake_player = 10
if hasattr(self, "_defending"):
self.player.dfn -= 5
del self._defending
if self.player.hp <= 0:
self.player.hp = 0
self.end_battle(False)
else:
self.state = "player_turn"
def end_battle(self, won):
if won:
xp = self.mon["xp"]
gold = self.mon["gold"]
self.player.gold += gold
lvl_msgs = self.player.gain_xp(xp)
self.add_log(f"Victory! +{xp} XP, +{gold}G")
for m in lvl_msgs:
self.add_log(m)
self.result_msgs = [f"Victory!", f"+{xp} XP +{gold} Gold"] + lvl_msgs
self.state = "win"
else:
self.player.hp = self.player.max_hp // 4
self.add_log("You were defeated...")
self.result_msgs = ["You fainted!", "Recovered at home."]
self.state = "lose"
def draw(self, surf):
self.frame += 1
# Background
for yy in range(H):
ratio = yy / H
r = int(20 + ratio * 20)
g = int(10 + ratio * 20)
b = int(40 + ratio * 30)
pygame.draw.line(surf, (r, g, b), (0, yy), (W, yy))
# Stars
random.seed(42)
for _ in range(40):
sx = random.randint(0, W)
sy = random.randint(0, H // 2)
br = 150 + int(50 * math.sin(self.frame * 0.05 + sx))
pygame.draw.rect(surf, (br, br, br), (sx, sy, 2, 2))
random.seed()
# Ground
pygame.draw.rect(surf, (60, 160, 60), (0, H-80, W, 80))
pygame.draw.rect(surf, (40, 120, 40), (0, H-82, W, 4))
# Enemy
ex = W * 3 // 4 - 24
ey = H // 2 - 40
sx = int(math.sin(self.shake_enemy * 0.8) * 5) if self.shake_enemy > 0 else 0
self.shake_enemy = max(0, self.shake_enemy - 1)
if self.mon["name"] in ("Green Slime", "Blue Slime"):
# Draw bigger slime
c = self.mon["color"]
for _ in range(3):
bc = int(abs(math.sin(self.frame * 0.1 + _)) * 3)
pygame.draw.ellipse(surf, c, (ex+sx+10, ey+bc+20, 60, 48))
pygame.draw.ellipse(surf, tuple(min(255,v+60) for v in c), (ex+sx+16, ey+24, 46, 30))
pygame.draw.rect(surf, C["white"], (ex+sx+22, ey+32, 10, 10))
pygame.draw.rect(surf, C["white"], (ex+sx+42, ey+32, 10, 10))
pygame.draw.rect(surf, C["dark"], (ex+sx+24, ey+34, 6, 6))
pygame.draw.rect(surf, C["dark"], (ex+sx+44, ey+34, 6, 6))
elif "Fairy" in self.mon["name"]:
bob = int(math.sin(self.frame * 0.2) * 6)
# Wings
pygame.draw.ellipse(surf, (200, 230, 255), (ex+sx-10, ey+bob+30, 30, 20))
pygame.draw.ellipse(surf, (200, 230, 255), (ex+sx+60, ey+bob+30, 30, 20))
pygame.draw.circle(surf, C["yellow"], (ex+sx+40, ey+bob+40), 22)
pygame.draw.circle(surf, C["white"], (ex+sx+40, ey+bob+40), 16)
pygame.draw.rect(surf, C["dark"], (ex+sx+32, ey+bob+34, 6, 6))
pygame.draw.rect(surf, C["dark"], (ex+sx+44, ey+bob+34, 6, 6))
elif "Wisp" in self.mon["name"]:
for ring in range(3):
alpha_c = 120 - ring * 30
r2 = 25 + ring * 10 + int(math.sin(self.frame * 0.1 + ring) * 4)
pygame.draw.circle(surf, C["purple"], (ex+sx+40, ey+40), r2, 2)
pygame.draw.circle(surf, C["purple"], (ex+sx+40, ey+40), 18)
pygame.draw.circle(surf, C["white"], (ex+sx+40, ey+40), 10)
# HP bar over enemy
draw_bar(surf, ex+sx+5, ey-12, 70, 8, self.mon_hp, self.mon_max_hp, C["hp_red"])
txt(surf, self.mon["name"], ex+sx-10, ey-28, C["white"], font_sm)
# Player sprite (left side)
psx = W // 4 - 12
psy = H // 2
pshake = int(math.sin(self.shake_player * 0.8) * 5) if self.shake_player > 0 else 0
self.shake_player = max(0, self.shake_player - 1)
draw_player(surf, psx + pshake, psy, self.frame, 3)
# Player stats panel
panel(surf, 10, H-130, 180, 120)
txt(surf, f"Lv.{self.player.level} {self.player.name}", 18, H-126, C["gold"])
draw_bar(surf, 18, H-108, 160, 10, self.player.hp, self.player.max_hp, C["hp_red"])
txt(surf, f"HP {self.player.hp}/{self.player.max_hp}", 18, H-96, C["white"], font_sm)
draw_bar(surf, 18, H-82, 160, 10, self.player.mp, self.player.max_mp, C["mp_blue"])
txt(surf, f"MP {self.player.mp}/{self.player.max_mp}", 18, H-70, C["white"], font_sm)
draw_bar(surf, 18, H-56, 160, 8, self.player.xp, self.player.xp_next, C["xp_green"])
txt(surf, f"XP {self.player.xp}/{self.player.xp_next}", 18, H-44, C["white"], font_sm)
# Battle log
panel(surf, W//2-160, H-130, 320, 70)
for i, line in enumerate(self.log[-3:]):
txt(surf, line, W//2-152, H-126 + i*22, C["white"], font_sm)
# Menu / result
if self.state in ("win", "lose"):
panel(surf, W//2-120, H//2-60, 240, 120)
color = C["gold"] if self.state == "win" else C["red"]
txt(surf, self.result_msgs[0], W//2-80, H//2-50, color)
for i, m in enumerate(self.result_msgs[1:]):
txt(surf, m, W//2-100, H//2-20 + i*22, C["white"], font_sm)
txt(surf, "Press E to continue", W//2-90, H//2+55, C["gray"], font_sm)
elif self.state == "player_turn":
if self.menu == "main":
panel(surf, W-200, H-130, 190, 120)
options = ["Attack", "Magic (8MP)", "Defend", "Items"]
for i, opt in enumerate(options):
color = C["yellow"] if i == self.selected else C["white"]
prefix = "" if i == self.selected else " "
txt(surf, prefix + opt, W-192, H-122 + i*26, color)
elif self.menu == "items":
panel(surf, W-220, H-150, 210, 140)
txt(surf, "Items:", W-212, H-146, C["gold"])
if not self.item_list:
txt(surf, "No items!", W-200, H-120, C["gray"])
else:
for i, (name, qty) in enumerate(self.item_list):
color = C["yellow"] if i == self.selected else C["white"]
prefix = "" if i == self.selected else " "
txt(surf, f"{prefix}{name} x{qty}", W-212, H-120 + i*24, color, font_sm)
txt(surf, "ESC=Back", W-212, H-18, C["gray"], font_sm)
def handle_input(self, event):
if event.type != pygame.KEYDOWN:
return None
if self.state in ("win", "lose"):
if event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
return self.state
return None
if self.state != "player_turn":
return None
if self.menu == "main":
if event.key == pygame.K_UP:
self.selected = (self.selected - 1) % 4
elif event.key == pygame.K_DOWN:
self.selected = (self.selected + 1) % 4
elif event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
if self.selected == 0:
self.player_attack()
elif self.selected == 1:
self.player_magic()
elif self.selected == 2:
self.player_defend()
# enemy attacks immediately
pygame.time.wait(400)
self.enemy_turn()
elif self.selected == 3:
self.item_list = [(n, q) for n, q in self.player.inventory.items() if n in ITEMS]
self.selected = 0
self.menu = "items"
elif self.menu == "items":
if event.key == pygame.K_ESCAPE:
self.menu = "main"
self.selected = 0
elif event.key == pygame.K_UP:
self.selected = max(0, self.selected - 1)
elif event.key == pygame.K_DOWN:
self.selected = min(len(self.item_list)-1, self.selected + 1)
elif event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
if self.item_list:
name, _ = self.item_list[self.selected]
ok, msg = self.player.use_item(name)
self.add_log(msg)
self.item_list = [(n, q) for n, q in self.player.inventory.items() if n in ITEMS]
if ok:
self.menu = "main"
self.selected = 0
self.state = "enemy_turn"
# Enemy responds if state changed
if self.state == "enemy_turn":
pygame.time.wait(300)
self.enemy_turn()
return None
# ── Shop ───────────────────────────────────────────────────────────────────
class Shop:
def __init__(self, player):
self.player = player
self.items = list(ITEMS.keys())
self.selected = 0
def draw(self, surf):
panel(surf, W//2-180, 60, 360, 360)
txt(surf, "= ITEM SHOP =", W//2-100, 72, C["gold"])
txt(surf, f"Gold: {self.player.gold}G", W//2+60, 72, C["yellow"], font_sm)
pygame.draw.line(surf, C["border"], (W//2-175, 95), (W//2+175, 95), 1)
for i, name in enumerate(self.items):
info = ITEMS[name]
y = 105 + i * 58
color = C["yellow"] if i == self.selected else C["white"]
prefix = "" if i == self.selected else " "
txt(surf, prefix + name, W//2-170, y, color)
txt(surf, info["desc"], W//2-155, y+20, C["gray"], font_sm)
txt(surf, f"{info['price']}G", W//2+120, y, C["gold"], font_sm)
txt(surf, "E=Buy ESC=Leave", W//2-80, 380, C["gray"], font_sm)
def handle_input(self, event):
if event.type != pygame.KEYDOWN:
return False
if event.key == pygame.K_ESCAPE:
return True # close
if event.key == pygame.K_UP:
self.selected = (self.selected - 1) % len(self.items)
elif event.key == pygame.K_DOWN:
self.selected = (self.selected + 1) % len(self.items)
elif event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
name = self.items[self.selected]
price = ITEMS[name]["price"]
if self.player.gold >= price:
self.player.gold -= price
self.player.add_item(name)
# else: not enough gold (silent)
return False
# ── Dialogue ───────────────────────────────────────────────────────────────
class Dialogue:
def __init__(self, lines):
self.lines = lines if isinstance(lines, list) else [lines]
self.idx = 0
@property
def done(self):
return self.idx >= len(self.lines)
def advance(self):
self.idx += 1
def draw(self, surf):
if self.done:
return
panel(surf, 30, H-110, W-60, 100)
line = self.lines[self.idx]
for i, l in enumerate(line.split("\n")):
txt(surf, l, 46, H-100 + i*22, C["white"])
txt(surf, "▼ E", W-70, H-24, C["gray"], font_sm)
# ── World Renderer ─────────────────────────────────────────────────────────
def tile_walkable(tile):
return tile not in ("W", "t", "#", "T")
def render_map(surf, map_data, cam, frame):
rows = len(map_data)
cols = len(map_data[0])
for row in range(rows):
for col in range(col_count := cols):
tile = map_data[row][col] if col < len(map_data[row]) else " "
wx = col * TILE
wy = row * TILE
sx, sy = cam.world_to_screen(wx, wy)
if sx > W+TILE or sy > H+TILE or sx < -TILE or sy < -TILE:
continue
# Base tile
grass_c = C["dark_grass"] if (row + col) % 2 == 0 else C["grass"]
pygame.draw.rect(surf, grass_c, (sx, sy, TILE, TILE))
if tile == "W":
pygame.draw.rect(surf, (100, 80, 60), (sx, sy, TILE, TILE))
pygame.draw.rect(surf, (80, 60, 40), (sx, sy, TILE, TILE), 2)
elif tile == "#":
water_off = int(math.sin(frame * 0.05 + col * 0.5) * 2)
pygame.draw.rect(surf, C["water"], (sx, sy+water_off, TILE, TILE))
pygame.draw.rect(surf, (100, 200, 255), (sx+4, sy+4+water_off, TILE-8, 4), 1)
elif tile == ".":
pass # just grass
elif tile == "P":
pass # just grass (player starts here)
elif tile == "M":
pass # NPC drawn separately
elif tile == "t":
draw_tree(surf, sx, sy - 8)
elif tile == "F":
# Forest entrance
pygame.draw.rect(surf, C["dark_grass"], (sx, sy, TILE, TILE))
pygame.draw.rect(surf, C["green"], (sx+4, sy+4, TILE-8, TILE-8), 2)
elif tile == "T":
# Town exit
pygame.draw.rect(surf, C["path"], (sx, sy, TILE, TILE))
elif tile == "H":
draw_house(surf, sx-8, sy-16)
elif tile == "S":
draw_store(surf, sx-8, sy-16)
# ── Main Game ──────────────────────────────────────────────────────────────
class Game:
TOWN = "town"
FOREST = "forest"
def __init__(self):
self.player = Player()
self.cam = Camera()
self.scene = self.TOWN
self.current_map = TOWN_MAP
self.dialogue = None
self.shop = None
self.battle = None
self.frame = 0
self.npc_frame = 0
self.message = None # (text, timer)
self.monster_encounter_timer = 0
self.encounter_rate = 60 # frames between encounter checks
self.mitsuha_dialogue_idx = 0
# Floating particles (sparkles)
self.sparks = []
def show_message(self, text, duration=120):
self.message = (text, duration)
def add_spark(self, x, y, color=None):
c = color or random.choice([C["yellow"], C["white"], C["gold"], C["pink"]])
self.sparks.append({
"x": x, "y": y, "vx": random.uniform(-1.5, 1.5),
"vy": random.uniform(-3, -0.5), "life": 40, "color": c
})
def get_tile(self, tx, ty):
m = self.current_map
if ty < 0 or ty >= len(m) or tx < 0 or tx >= len(m[ty]):
return "W"
return m[ty][tx]
def try_move(self, dx, dy):
nx = self.player.x + dx
ny = self.player.y + dy
if not tile_walkable(self.get_tile(nx, ny)):
return
# Check transitions
if self.scene == self.TOWN and self.get_tile(nx, ny) == "F":
self.scene = self.FOREST
self.current_map = FOREST_MAP
self.player.x, self.player.y = 10, 12
self.player.px = self.player.x * TILE
self.player.py = self.player.y * TILE
self.show_message("Entered the Forest!")
return
if self.scene == self.FOREST and self.get_tile(nx, ny) == "T":
self.scene = self.TOWN
self.current_map = TOWN_MAP
self.player.x, self.player.y = 9, 12
self.player.px = self.player.x * TILE
self.player.py = self.player.y * TILE
self.show_message("Back in town!")
return
self.player.x = nx
self.player.y = ny
self.player.px = nx * TILE
self.player.py = ny * TILE
if dx > 0: self.player.facing = 3
elif dx < 0: self.player.facing = 2
elif dy > 0: self.player.facing = 1
else: self.player.facing = 0
# Random encounter in forest
if self.scene == self.FOREST:
if random.random() < 0.2:
mon = random.choice(MONSTERS)
self.battle = Battle(self.player, mon)
def try_interact(self):
# Check adjacent tiles for interactive objects
fx, fy = self.player.x, self.player.y
facing = self.player.facing
if facing == 0: fy -= 1
elif facing == 1: fy += 1
elif facing == 2: fx -= 1
elif facing == 3: fx += 1
tile = self.get_tile(fx, fy)
# Check if near Mitsuha (in town map)
if self.scene == self.TOWN:
for row_i, row in enumerate(TOWN_MAP):
if "M" in row:
mx, my = row.index("M"), row_i
if abs(mx - self.player.x) + abs(my - self.player.y) <= 1:
line = MITSUHA_LINES[self.mitsuha_dialogue_idx % len(MITSUHA_LINES)]
self.mitsuha_dialogue_idx += 1
self.dialogue = Dialogue(line)
return
if tile == "H":
# Home - rest
self.player.hp = self.player.max_hp
self.player.mp = self.player.max_mp
self.dialogue = Dialogue(random.choice(HOME_LINES) + "\n(HP and MP restored!)")
for _ in range(12):
self.add_spark(self.player.px + random.randint(-20, 20),
self.player.py + random.randint(-30, 10), C["gold"])
elif tile == "S":
self.shop = Shop(self.player)
def update(self):
self.frame += 1
self.npc_frame += 1
self.player.frame += 1
# Update sparks
for s in self.sparks[:]:
s["x"] += s["vx"]
s["y"] += s["vy"]
s["vy"] += 0.1
s["life"] -= 1
if s["life"] <= 0:
self.sparks.remove(s)
if self.message:
text, timer = self.message
self.message = (text, timer - 1) if timer > 0 else None
def draw_world(self, surf):
surf.fill(C["sky"])
render_map(surf, self.current_map, self.cam, self.frame)
# Draw Mitsuha NPC (in town)
if self.scene == self.TOWN:
for row_i, row in enumerate(TOWN_MAP):
if "M" in row:
mx, my = row.index("M"), row_i
sx, sy = self.cam.world_to_screen(mx * TILE, my * TILE)
draw_npc_mitsuha(surf, sx, sy, self.npc_frame)
# Name tag
panel(surf, sx-4, sy-20, 56, 16)
txt(surf, "Mitsuha", sx-2, sy-19, C["pink"], font_sm)
# Draw player
px, py = self.cam.world_to_screen(self.player.px, self.player.py)
draw_player(surf, px, py, self.player.frame, self.player.facing)
# Sparks
for s in self.sparks:
alpha = max(0, s["life"] * 6)
c = tuple(min(255, v) for v in s["color"])
pygame.draw.rect(surf, c, (int(s["x"]) - self.cam.ox, int(s["y"]) - self.cam.oy, 3, 3))
def draw_hud(self, surf):
# Mini stats
panel(surf, 8, 8, 200, 68)
txt(surf, f"Lv.{self.player.level} {self.player.name}", 14, 12, C["gold"])
draw_bar(surf, 14, 32, 180, 8, self.player.hp, self.player.max_hp, C["hp_red"])
txt(surf, f"HP {self.player.hp}/{self.player.max_hp}", 14, 42, C["white"], font_sm)
draw_bar(surf, 14, 54, 180, 8, self.player.mp, self.player.max_mp, C["mp_blue"])
txt(surf, f"MP {self.player.mp}/{self.player.max_mp}", 14, 64, C["white"], font_sm)
# Gold
panel(surf, W-140, 8, 132, 26)
txt(surf, f"Gold: {self.player.gold}G", W-134, 12, C["gold"])
# Location
loc = "** Village **" if self.scene == self.TOWN else "~~ Forest ~~"
panel(surf, W//2-60, 8, 120, 26)
txt(surf, loc, W//2-52, 12, C["white"], font_sm)
# Controls hint
txt(surf, "Arrows:Move E:Interact", 10, H-18, C["gray"], font_sm)
# Floating message
if self.message:
text, timer = self.message
alpha = min(255, timer * 4)
panel(surf, W//2-120, H//2-20, 240, 40)
fc = flash_color(C["white"], self.frame * 0.02)
txt(surf, text, W//2-100, H//2-12, fc)
def run(self):
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
if self.dialogue:
self.dialogue = None
elif self.shop:
self.shop = None
elif self.battle:
pass # can't escape battle easily
else:
running = False
# Battle input
if self.battle:
result = self.battle.handle_input(event)
if result in ("win", "lose"):
if result == "lose":
self.scene = self.TOWN
self.current_map = TOWN_MAP
self.player.x, self.player.y = 5, 6
self.player.px = self.player.x * TILE
self.player.py = self.player.y * TILE
self.battle = None
continue
# Shop input
if self.shop:
if self.shop.handle_input(event):
self.shop = None
continue
# Dialogue
if self.dialogue:
if event.type == pygame.KEYDOWN and event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
self.dialogue.advance()
if self.dialogue.done:
self.dialogue = None
continue
# Movement
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_UP:
self.try_move(0, -1)
elif event.key == pygame.K_DOWN:
self.try_move(0, 1)
elif event.key == pygame.K_LEFT:
self.try_move(-1, 0)
elif event.key == pygame.K_RIGHT:
self.try_move(1, 0)
elif event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
self.try_interact()
self.update()
self.cam.update(self.player.px, self.player.py,
len(self.current_map[0]), len(self.current_map))
if self.battle:
self.battle.draw(screen)
else:
self.draw_world(screen)
self.draw_hud(screen)
if self.shop:
self.shop.draw(screen)
if self.dialogue:
self.dialogue.draw(screen)
pygame.display.flip()
clock.tick(FPS)
pygame.quit()
sys.exit()
# ── Title Screen ───────────────────────────────────────────────────────────
def title_screen():
frame = 0
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit(); sys.exit()
if event.type == pygame.KEYDOWN:
return
frame += 1
screen.fill(C["bg"])
# Stars
random.seed(7)
for _ in range(60):
sx = random.randint(0, W)
sy = random.randint(0, H)
br = 100 + int(80 * math.sin(frame * 0.03 + sx * 0.1))
pygame.draw.rect(screen, (br, br, int(br*0.8)), (sx, sy, 2, 2))
random.seed()
# Animated title shimmer
tc = flash_color(C["gold"], frame * 0.04, 30)
title = font_lg.render(">> Pixel Village <<", True, tc)
screen.blit(title, (W//2 - title.get_width()//2, 100))
sub = font_md.render("~ A Cozy Little JRPG ~", True, C["pink"])
screen.blit(sub, (W//2 - sub.get_width()//2, 140))
# Demo sprites
draw_player(screen, W//2-80, 230, frame)
draw_npc_mitsuha(screen, W//2-20, 230, frame)
draw_slime(screen, W//2+60, 240, frame, C["green"])
draw_fairy(screen, W//2+100, 230, frame)
# Features
features = [
"[H] Cozy village with shop & home",
"[?] Chat with neighbor Mitsuha",
"[T] Forest full of cute monsters",
"[!] Turn-based combat",
"[*] Level up your hero!",
]
for i, f in enumerate(features):
fc = C["white"] if i % 2 == 0 else C["gray"]
fs = font_sm.render(f, True, fc)
screen.blit(fs, (W//2 - fs.get_width()//2, 310 + i*20))
blink = int(frame / 30) % 2 == 0
if blink:
ps = font_md.render("Press any key to start!", True, C["yellow"])
screen.blit(ps, (W//2 - ps.get_width()//2, 420))
pygame.display.flip()
clock.tick(FPS)
if __name__ == "__main__":
title_screen()
game = Game()
game.run()